"""Twitch's public desktop OAuth provider and trusted, bounded Helix actions. Twitch's device grant is deliberately used instead of embedding a client secret in the desktop app. The user registers a *public* Twitch application and enters only its client ID. Tokens never belong in a plugin job, API response or error. This provider does not claim that watching a channel grants management rights. """ from __future__ import annotations import math import re import time from datetime import datetime from typing import Any, ClassVar from urllib.parse import urlsplit import requests from connected_accounts.models import AccountError _AUTH = "https://id.twitch.tv/oauth2" _HELIX = "https://api.twitch.tv/helix" _ID = re.compile(r"[0-9]{1,30}\Z") _CLIENT_ID = re.compile(r"[A-Za-z0-9_-]{1,128}\Z") _SCOPES = frozenset( {"clips:edit", "channel:manage:clips", "editor:manage:clips", "user:read:chat", "user:write:chat"} ) _CLIP_ID = re.compile(r"[A-Za-z0-9_-]{1,200}\Z") _DURATION = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?\Z") _EVENTS = { "stream.online": ("1", "broadcaster_user_id"), "stream.offline": ("1", "broadcaster_user_id"), "channel.update": ("2", "broadcaster_user_id"), "channel.chat.message": ("1", "broadcaster_user_id"), } def _text(value: Any, *, maximum: int = 4096) -> str: return value[:maximum] if isinstance(value, str) else "" def _numeric_id(value: Any, name: str) -> str: if not isinstance(value, str) or not _ID.fullmatch(value): raise AccountError("invalid_request", f"A valid Twitch {name} is required.") return value def _scopes(value: Any) -> set[str]: if isinstance(value, str): return set(value.split()) if isinstance(value, (list, tuple, set)) and all(isinstance(item, str) for item in value): return set(value) return set() def _positive_int(value: Any, *, default: int | None = None, maximum: int = 86400) -> int: if isinstance(value, bool): value = None try: result = int(value) except (ValueError, TypeError, OverflowError): result = default if result is None or not 0 < result <= maximum: raise AccountError("provider_response", "Twitch returned an invalid response.", status=502) return result def _verification_url(value: Any) -> str: if not isinstance(value, str): raise AccountError("provider_response", "Twitch did not provide a verification page.", status=502) try: parsed = urlsplit(value) valid_port = parsed.port in (None, 443) except ValueError: raise AccountError( "provider_response", "Twitch returned an unexpected verification page.", status=502 ) from None if not ( parsed.scheme == "https" and parsed.hostname in {"twitch.tv", "www.twitch.tv"} and parsed.path.rstrip("/") == "/activate" and not parsed.username and not parsed.password and valid_port and not parsed.fragment ): raise AccountError( "provider_response", "Twitch returned an unexpected verification page.", status=502 ) return value def _duration_seconds(value: Any) -> int | None: """Parse the bounded h/m/s duration returned by Helix; never guess zero.""" if not isinstance(value, str) or len(value) > 100: return None match = _DURATION.fullmatch(value) if not match or not any(part is not None for part in match.groups()): return None hours, minutes, seconds = (int(part or 0) for part in match.groups()) total = hours * 3600 + minutes * 60 + seconds return total if minutes < 60 and seconds < 60 and total <= 31536000 else None def _clip_id(value: Any) -> str: if not isinstance(value, str) or not _CLIP_ID.fullmatch(value): raise AccountError("invalid_request", "A valid Twitch clip ID is required.") return value def _rfc3339(value: Any) -> datetime: if not isinstance(value, str) or not re.fullmatch( r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})", value ): raise AccountError("invalid_request", "Use RFC3339 timestamps with a time zone.") try: return datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: raise AccountError("invalid_request", "Use valid RFC3339 timestamps.") from None def _clip_url(value: Any, clip_id: str) -> str: """Accept only an actual public URL returned by Helix, stripping tracking.""" try: parsed = urlsplit(value if isinstance(value, str) else "") port = parsed.port except ValueError: parsed, port = None, None valid = ( parsed is not None and parsed.scheme == "https" and parsed.hostname in {"clips.twitch.tv", "www.twitch.tv", "twitch.tv"} and not parsed.username and not parsed.password and port in (None, 443) ) if valid: valid = ( parsed.path == f"/{clip_id}" if parsed.hostname == "clips.twitch.tv" else re.fullmatch(rf"/[A-Za-z0-9_]{{1,25}}/clip/{re.escape(clip_id)}", parsed.path) ) if not valid: raise AccountError("provider_response", "Twitch did not return a public clip URL.", status=502) return f"https://{parsed.hostname}{parsed.path}" class TwitchProvider: """One public Twitch client registration; account tokens stay in the host.""" id = "twitch" # Nothing beyond reading the channel unless the person ticks a permission: # the app itself never makes a live clip (plugins work on finished VODs). default_scopes: tuple[str, ...] = () supported_scopes = _SCOPES capabilities: ClassVar[dict[str, bool]] = { "device_code": True, "authorization_code": False, "pkce": False, } operation_scopes: ClassVar[dict[str, tuple[str, ...]]] = { "stream_context": (), "videos": (), "clips": (), "create_clip": ("clips:edit",), # These grants are alternatives, checked explicitly in perform(). "create_vod_clip": (), "get_clip": (), "vod_link": (), "send_chat": ("user:write:chat",), "subscribe_events": (), # Chat subscriptions additionally require user:read:chat. "unsubscribe_events": (), } def __init__(self, client_id: str, *, session=None): if not isinstance(client_id, str) or (client_id and not _CLIENT_ID.fullmatch(client_id)): raise AccountError("invalid_client", "Enter the client ID of a public Twitch application.") self.client_id = client_id self._session = session or requests.Session() self._closed = False self._rate_until: dict[str, float] = {} def close(self) -> None: """Release transport resources when the host replaces or stops us.""" if self._closed: return self._closed = True close = getattr(self._session, "close", None) if callable(close): try: close() except Exception: # Cleanup is best effort and must not expose credential-bearing # transport errors while disconnecting or shutting down. pass def _request( self, method: str, url: str, *, access_token: str | None = None, validation: bool = False, **kwargs ) -> dict: if self._closed: raise AccountError( "provider_unavailable", "The Twitch connection service has stopped.", status=503 ) if not self.client_id: raise AccountError( "provider_not_configured", "Configure a public Twitch client ID before connecting.", status=409, ) headers = {"Accept": "application/json"} if access_token: headers["Authorization"] = f"{'OAuth' if validation else 'Bearer'} {access_token}" if url.startswith(_HELIX + "/"): headers["Client-Id"] = self.client_id if self._rate_until.get(access_token or "", 0) > time.time(): raise AccountError("rate_limited", "Twitch's request budget resets shortly. Try again later.", status=429) try: response = self._session.request( method, url, headers=headers, timeout=(5, 20), allow_redirects=False, **kwargs ) except requests.RequestException: raise AccountError( "provider_unavailable", "Twitch could not be reached. Try again.", status=503 ) from None if url.startswith(_HELIX + "/"): rate = getattr(response, "headers", {}) if response.status_code == 429 or rate.get("Ratelimit-Remaining") == "0": try: reset = float(rate.get("Ratelimit-Reset", time.time() + 60)) except (TypeError, ValueError): reset = time.time() + 60 if not math.isfinite(reset): reset = time.time() + 60 self._rate_until[access_token or ""] = max(time.time() + 1, reset) try: data = response.json() if response.status_code != 204 else {} except (ValueError, TypeError): data = {} if not isinstance(data, dict): data = {} if response.status_code == 401: raise AccountError("invalid_token", "Twitch authorization expired. Reconnect Twitch.", status=401) if response.status_code == 429: if data.get("error") == "slow_down" or data.get("message") == "slow_down": raise AccountError("slow_down", "Wait before checking Twitch authorization again.") raise AccountError( "rate_limited", "Twitch is receiving too many requests. Try again later.", status=429 ) if not 200 <= response.status_code < 300: # Providers sometimes put an OAuth code in `message`, sometimes # in `error`. Never relay the response body: it may echo a token. code = data.get("error") or data.get("message") known = { "authorization_pending": ("authorization_pending", "Approve the connection on Twitch."), "slow_down": ("slow_down", "Wait before checking Twitch authorization again."), "access_denied": ("access_denied", "Twitch authorization was declined."), "user_denied": ("access_denied", "Twitch authorization was declined."), "expired_token": ("expired_token", "The Twitch connection request expired. Start again."), "invalid_device_code": ( "expired_token", "The Twitch connection request expired. Start again.", ), "invalid_grant": ( "invalid_grant", "Twitch authorization is no longer valid. Reconnect Twitch.", ), "Invalid refresh token": ( "invalid_grant", "Twitch authorization is no longer valid. Reconnect Twitch.", ), "invalid_client": ( "invalid_client", "Check the client ID and public client setting in Twitch.", ), "invalid client": ( "invalid_client", "Check the client ID and public client setting in Twitch.", ), } mapped = known.get(code) if isinstance(code, str) else None if mapped: error, message = mapped raise AccountError(error, message, status=401 if error == "invalid_grant" else 400) if response.status_code == 403: raise AccountError( "permission_denied", "Twitch did not permit this action for this account or channel.", status=403, ) if response.status_code == 404: raise AccountError( "not_found", "The requested Twitch stream, video, or clip is unavailable.", status=404 ) if response.status_code >= 500: raise AccountError( "provider_unavailable", "Twitch is temporarily unavailable. Try again.", status=503 ) raise AccountError( "provider_error", "Twitch did not accept the request. Check the account permissions.", status=502, ) return data def begin(self, scopes) -> dict: requested = _scopes(scopes) if requested - _SCOPES: raise AccountError("invalid_scope", "This Twitch permission is not supported by Clips Kitty.") data = self._request( "POST", _AUTH + "/device", data={ "client_id": self.client_id, "scopes": " ".join(sorted(requested)), }, ) device = _text(data.get("device_code"), maximum=8192) user = _text(data.get("user_code"), maximum=256) if not device or not user: raise AccountError("provider_response", "Twitch did not provide a connection code.", status=502) result = { "device_code": device, "user_code": user, "verification_uri": _verification_url(data.get("verification_uri")), "expires_in": _positive_int(data.get("expires_in"), maximum=3600), "interval": _positive_int(data.get("interval"), default=5, maximum=60), } if data.get("verification_uri_complete"): result["verification_uri_complete"] = _verification_url(data["verification_uri_complete"]) return result @staticmethod def _token(data: dict) -> dict: access = data.get("access_token") refresh = data.get("refresh_token") if not isinstance(access, str) or not access or len(access) > 8192: raise AccountError("provider_response", "Twitch did not return an access token.", status=502) if refresh is not None and (not isinstance(refresh, str) or len(refresh) > 8192): raise AccountError( "provider_response", "Twitch returned an invalid authorization response.", status=502 ) if str(data.get("token_type", "bearer")).lower() != "bearer": raise AccountError("provider_response", "Twitch returned an unsupported token type.", status=502) token = { "access_token": access, "expires_in": _positive_int(data.get("expires_in")), "scope": sorted(_scopes(data.get("scope", []))), "token_type": "bearer", } if refresh: token["refresh_token"] = refresh return token def poll(self, device_code: str) -> dict: if not isinstance(device_code, str) or not device_code or len(device_code) > 8192: raise AccountError("invalid_request", "The Twitch connection request is invalid.") return self._token( self._request( "POST", _AUTH + "/token", data={ "client_id": self.client_id, "device_code": device_code, "grant_type": "urn:ietf:params:oauth:grant-type:device_code", }, ) ) def refresh(self, refresh_token: str) -> dict: if not isinstance(refresh_token, str) or not refresh_token: raise AccountError("invalid_grant", "Reconnect Twitch to renew the authorization.", status=401) return self._token( self._request( "POST", _AUTH + "/token", data={ "client_id": self.client_id, "refresh_token": refresh_token, "grant_type": "refresh_token", }, ) ) def validate(self, access_token: str) -> dict: data = self._request("GET", _AUTH + "/validate", access_token=access_token, validation=True) if data.get("client_id") != self.client_id: raise AccountError( "invalid_token", "Twitch authorization belongs to another application. Reconnect Twitch.", status=401, ) user_id = _numeric_id(data.get("user_id"), "account ID") return { "client_id": self.client_id, "user_id": user_id, "login": _text(data.get("login"), maximum=100), "scopes": sorted(_scopes(data.get("scopes"))), "expires_in": _positive_int(data.get("expires_in")), } def identity(self, access_token: str) -> dict: data = self._request("GET", _HELIX + "/users", access_token=access_token) users = data.get("data") if not isinstance(users, list) or len(users) != 1 or not isinstance(users[0], dict): raise AccountError( "provider_response", "Twitch did not identify the connected account.", status=502 ) user = users[0] return { "id": _numeric_id(user.get("id"), "account ID"), "login": _text(user.get("login"), maximum=100), "display_name": _text(user.get("display_name"), maximum=100), } def revoke(self, access_token: str, *, client_id: str | None = None) -> None: """Revoke a token. Twitch only accepts the client ID that issued it, which differs from ours after the Client ID setting changed.""" issuer = self.client_id if client_id is None else client_id if not isinstance(issuer, str) or not _CLIENT_ID.fullmatch(issuer): raise AccountError("invalid_client", "Enter the client ID of a public Twitch application.") self._request("POST", _AUTH + "/revoke", data={"client_id": issuer, "token": access_token}) def _helix(self, method: str, path: str, access_token: str, **kwargs) -> dict: return self._request(method, _HELIX + path, access_token=access_token, **kwargs) @staticmethod def _rows(data: dict) -> list[dict]: rows = data.get("data") if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): raise AccountError("provider_response", "Twitch returned an invalid response.", status=502) return rows @staticmethod def _video(row: dict) -> dict: video_id = _numeric_id(row.get("id"), "video ID") return { "id": video_id, "user_id": row.get("user_id") if isinstance(row.get("user_id"), str) and _ID.fullmatch(row["user_id"]) else "", "stream_id": row.get("stream_id") if row.get("type") == "archive" and isinstance(row.get("stream_id"), str) and _ID.fullmatch(row["stream_id"]) else None, "type": _text(row.get("type"), maximum=30), "title": _text(row.get("title")), "url": f"https://www.twitch.tv/videos/{video_id}", "created_at": _text(row.get("created_at"), maximum=100), "published_at": _text(row.get("published_at"), maximum=100), "duration": _text(row.get("duration"), maximum=100), "duration_seconds": _duration_seconds(row.get("duration")), "view_count": row.get("view_count", 0) if isinstance(row.get("view_count", 0), int) else 0, } def _owned_video(self, access_token: str, broadcaster: str, video_id: str) -> dict: rows = self._rows(self._helix("GET", "/videos", access_token, params={"id": video_id})) if not rows: raise AccountError("not_found", "This Twitch VOD is no longer available.", status=404) if len(rows) != 1 or rows[0].get("id") != video_id: raise AccountError("provider_response", "Twitch returned an unexpected VOD.", status=502) if rows[0].get("user_id") != broadcaster: raise AccountError( "permission_denied", "The Twitch VOD does not belong to the selected channel.", status=403 ) return self._video(rows[0]) def perform(self, access_token: str, account: dict, operation: str, **params) -> dict: """Called by the account host after its plugin/channel permission gate. Subject- and scope-sensitive requirements are checked again here. The host must bind broadcaster IDs to an approved watch or plugin job. No operation accepts a URL, authorization header, or arbitrary API path. """ if operation not in self.operation_scopes: raise AccountError("unsupported_operation", "This Twitch action is not supported.") permitted_params = { "clips": {"broadcaster_id", "broadcaster_login", "started_at", "ended_at", "first", "after"}, "stream_context": {"broadcaster_id", "broadcaster_login", "first"}, "videos": {"broadcaster_id", "first"}, "create_clip": {"broadcaster_id", "has_delay"}, "create_vod_clip": {"broadcaster_id", "video_id", "start_seconds", "end_seconds", "title"}, "get_clip": {"broadcaster_id", "clip_id", "video_id"}, "vod_link": {"broadcaster_id", "video_id", "seconds"}, "send_chat": {"broadcaster_id", "message"}, "subscribe_events": {"broadcaster_id", "event_types", "session_id"}, "unsubscribe_events": {"broadcaster_id", "subscription_id"}, } if set(params) - permitted_params[operation]: raise AccountError("invalid_request", "This Twitch action received an unsupported parameter.") scopes = _scopes(account.get("scopes")) required = set(self.operation_scopes[operation]) if not required <= scopes: raise AccountError( "missing_scope", "Reconnect Twitch and allow the permission for this action.", status=403 ) subject = _numeric_id(account.get("user_id"), "account ID") broadcaster = _numeric_id(params.get("broadcaster_id", subject), "channel ID") if operation == "clips": first = params.get("first", 100) if isinstance(first, bool) or not isinstance(first, int) or not 1 <= first <= 100: raise AccountError("invalid_request", "Choose between 1 and 100 Twitch clips.") dates = {key: _rfc3339(params[key]) for key in ("started_at", "ended_at") if key in params} if len(dates) == 2 and dates["started_at"] >= dates["ended_at"]: raise AccountError("invalid_request", "The clip window must end after it starts.") cursor = params.get("after", "") if not isinstance(cursor, str) or len(cursor) > 1024 or any(ord(c) < 32 for c in cursor): raise AccountError("invalid_request", "Use a valid Twitch pagination cursor.") resolved_user = None if "broadcaster_login" in params: login = params["broadcaster_login"] if ("broadcaster_id" in params or not isinstance(login, str) or not re.fullmatch(r"[A-Za-z0-9_]{1,25}", login)): raise AccountError("invalid_request", "Choose a Twitch channel name.") users = self._rows(self._helix("GET", "/users", access_token, params={"login": login.lower()})) if not users: raise AccountError("not_found", "The Twitch channel was not found.", status=404) if len(users) != 1 or str(users[0].get("login", "")).lower() != login.lower(): raise AccountError("provider_response", "Twitch returned an unexpected channel.", status=502) resolved_user = users[0] broadcaster = _numeric_id(resolved_user.get("id"), "channel ID") if operation in {"create_vod_clip", "get_clip"} and broadcaster != subject: raise AccountError( "permission_denied", "VOD clip actions are limited to the connected streamer's channel.", status=403, ) if operation == "create_vod_clip" and not scopes & {"channel:manage:clips", "editor:manage:clips"}: raise AccountError( "missing_scope", "Reconnect Twitch and allow managing clips for this action.", status=403 ) if operation in {"stream_context", "videos"}: first = params.get("first", 20) if not isinstance(first, int) or isinstance(first, bool) or not 1 <= first <= 100: raise AccountError("invalid_request", "Choose between 1 and 100 Twitch videos.") videos = self._rows( self._helix( "GET", "/videos", access_token, params={"user_id": broadcaster, "type": "archive", "first": first}, ) ) result = {"videos": [self._video(row) for row in videos]} if operation == "videos": return result users = ([resolved_user] if resolved_user is not None else self._rows(self._helix("GET", "/users", access_token, params={"id": broadcaster}))) streams = self._rows( self._helix("GET", "/streams", access_token, params={"user_id": broadcaster}) ) if not users: raise AccountError("not_found", "The Twitch channel was not found.", status=404) user = users[0] if user.get("id") != broadcaster: raise AccountError("provider_response", "Twitch returned an unexpected channel.", status=502) result["broadcaster"] = { "id": _numeric_id(user.get("id"), "channel ID"), "login": _text(user.get("login"), maximum=100), "display_name": _text(user.get("display_name"), maximum=100), } stream = streams[0] if streams else None result["stream"] = ( {key: _text(stream.get(key)) for key in ("id", "title", "game_id", "game_name", "started_at")} if stream else None ) if result["stream"] is not None: result["stream"]["viewer_count"] = stream.get("viewer_count", 0) return result if operation == "clips": query = {"broadcaster_id": broadcaster, "first": first} query.update({key: params[key] for key in ("started_at", "ended_at", "after") if key in params}) data = self._helix("GET", "/clips", access_token, params=query) clips = [] for row in self._rows(data): if row.get("broadcaster_id") != broadcaster: raise AccountError("permission_denied", "The clip does not belong to the requested channel.", status=403) clip_id = _clip_id(row.get("id")) views, duration, offset = row.get("view_count"), row.get("duration"), row.get("vod_offset") if (isinstance(views, bool) or not isinstance(views, int) or views < 0 or isinstance(duration, bool) or not isinstance(duration, (int, float)) or not math.isfinite(duration) or not 0 < duration <= 31536000 or (offset is not None and (isinstance(offset, bool) or not isinstance(offset, int) or not 0 <= offset <= 31536000))): raise AccountError("provider_response", "Twitch returned invalid clip metadata.", status=502) clips.append({ "id": clip_id, "url": _clip_url(row.get("url"), clip_id), "title": _text(row.get("title")), "view_count": views, "created_at": _text(row.get("created_at"), maximum=100), "duration": duration, "creator_name": _text(row.get("creator_name"), maximum=100), "game_id": _text(row.get("game_id"), maximum=30), "video_id": _text(row.get("video_id"), maximum=30), "vod_offset": offset, }) pagination = data.get("pagination", {}) return {"clips": clips, "cursor": _text(pagination.get("cursor"), maximum=1024) if isinstance(pagination, dict) else ""} if operation == "create_clip": has_delay = params.get("has_delay", False) if not isinstance(has_delay, bool): raise AccountError("invalid_request", "The Twitch clip delay setting must be true or false.") rows = self._rows( self._helix( "POST", "/clips", access_token, params={"broadcaster_id": broadcaster, "has_delay": str(has_delay).lower()}, ) ) if not rows or not re.fullmatch(r"[A-Za-z0-9_-]{1,200}", str(rows[0].get("id", ""))): raise AccountError("provider_response", "Twitch did not return a clip ID.", status=502) clip_id = rows[0]["id"] # Never expose edit_url, which is not the public share URL. return { "kind": "native_clip", "id": clip_id, "status": "processing", "url": f"https://clips.twitch.tv/{clip_id}", } if operation == "create_vod_clip": # These are explicitly VOD-relative positions. The host must first # validate any local-file-to-VOD mapping; wall-clock dates are not a # calibration and are deliberately not accepted by this operation. video_id = _numeric_id(params.get("video_id"), "video ID") start, end = params.get("start_seconds"), params.get("end_seconds") if any( isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 31536000 or not math.isfinite(value) for value in (start, end) ): raise AccountError("invalid_request", "Provide finite VOD-relative clip timestamps.") duration = round(end - start, 1) if ( end != int(end) or not 5 <= duration <= 60 or not math.isclose(end - start, duration, rel_tol=0, abs_tol=1e-8) ): raise AccountError( "invalid_request", "A VOD clip needs a whole-second end and 5 to 60 seconds at 0.1-second precision.", ) title = params.get("title") # A local bounded policy, not a claimed Twitch API title limit. if ( not isinstance(title, str) or not title.strip() or len(title) > 140 or any(ord(char) < 32 or ord(char) == 127 for char in title) ): raise AccountError( "invalid_request", "Use a clip title of 1 to 140 characters without control characters." ) video = self._owned_video(access_token, broadcaster, video_id) if video["type"] != "archive" or not video["stream_id"] or video["duration_seconds"] is None: raise AccountError( "provider_response", "Twitch did not verify this archive's stream and duration.", status=502, ) if end > video["duration_seconds"]: raise AccountError("invalid_request", "The clip extends beyond the available Twitch VOD.") rows = self._rows( self._helix( "POST", "/videos/clips", access_token, params={ "editor_id": subject, "broadcaster_id": broadcaster, "vod_id": video_id, "vod_offset": int(end), "duration": duration, "title": title.strip(), }, ) ) if ( len(rows) != 1 or not isinstance(rows[0].get("id"), str) or not _CLIP_ID.fullmatch(rows[0]["id"]) ): raise AccountError("provider_response", "Twitch did not return a clip ID.", status=502) return { "kind": "native_vod_clip", "id": rows[0]["id"], "status": "processing", "video_id": video_id, "broadcaster_id": broadcaster, "stream_id": video["stream_id"], "start_seconds": start, "end_seconds": int(end), "duration": duration, } if operation == "get_clip": clip_id = _clip_id(params.get("clip_id")) expected_video = params.get("video_id") if expected_video is not None: expected_video = _numeric_id(expected_video, "video ID") rows = self._rows(self._helix("GET", "/clips", access_token, params={"id": clip_id})) pending = {"kind": "native_vod_clip", "id": clip_id, "status": "pending"} if not rows: return pending if len(rows) != 1 or rows[0].get("id") != clip_id: raise AccountError("provider_response", "Twitch returned an unexpected clip.", status=502) row = rows[0] if row.get("broadcaster_id") != broadcaster: raise AccountError( "permission_denied", "The clip does not belong to the connected channel.", status=403 ) video_id = row.get("video_id") if video_id in (None, ""): if expected_video is not None: return pending video = None video_id = "" else: video_id = _numeric_id(video_id, "video ID") if expected_video is not None and video_id != expected_video: raise AccountError( "permission_denied", "The clip does not belong to the expected Twitch VOD.", status=403, ) video = self._owned_video(access_token, broadcaster, video_id) if expected_video is not None and (video["type"] != "archive" or not video["stream_id"]): raise AccountError( "provider_response", "Twitch did not verify the clip's source stream.", status=502 ) offset = row.get("vod_offset") if offset is not None and ( isinstance(offset, bool) or not isinstance(offset, int) or not 0 <= offset <= 31536000 ): raise AccountError("provider_response", "Twitch returned an invalid clip offset.", status=502) duration = row.get("duration") if ( isinstance(duration, bool) or not isinstance(duration, (int, float)) or not 0 < duration <= 31536000 or not math.isfinite(duration) ): raise AccountError( "provider_response", "Twitch returned an invalid clip duration.", status=502 ) return { "kind": "native_vod_clip", "id": clip_id, "status": "ready", "url": _clip_url(row.get("url"), clip_id), "broadcaster_id": broadcaster, "video_id": video_id, "vod_offset": offset, "duration": duration, "stream_id": video["stream_id"] if video else None, } if operation == "vod_link": video_id = _numeric_id(params.get("video_id"), "video ID") seconds = params.get("seconds", 0) if ( isinstance(seconds, bool) or not isinstance(seconds, (int, float)) or not 0 <= seconds <= 31536000 or not math.isfinite(seconds) ): raise AccountError( "invalid_request", "The Twitch video timestamp must be a nonnegative number." ) video = self._owned_video(access_token, broadcaster, video_id) if video["duration_seconds"] is not None and seconds > video["duration_seconds"]: raise AccountError("invalid_request", "The timestamp is outside the available Twitch VOD.") seconds = int(seconds) hours, rem = divmod(seconds, 3600) minutes, secs = divmod(rem, 60) return { "kind": "timestamp", "video_id": video_id, "seconds": seconds, "broadcaster_id": broadcaster, "stream_id": video["stream_id"], "duration": video["duration"], "duration_seconds": video["duration_seconds"], "type": video["type"], "url": f"https://www.twitch.tv/videos/{video_id}?t={hours}h{minutes}m{secs}s", } if operation == "send_chat": message = params.get("message") if not isinstance(message, str) or not message.strip() or len(message) > 500: raise AccountError( "invalid_request", "A Twitch chat message must contain 1 to 500 characters." ) rows = self._rows( self._helix( "POST", "/chat/messages", access_token, json={"broadcaster_id": broadcaster, "sender_id": subject, "message": message}, ) ) if not rows: raise AccountError( "provider_response", "Twitch did not confirm the chat message.", status=502 ) return { "message_id": _text(rows[0].get("message_id"), maximum=100), "is_sent": rows[0].get("is_sent") is True, } if operation == "unsubscribe_events": subscription_id = params.get("subscription_id") if not isinstance(subscription_id, str) or not re.fullmatch( r"[A-Za-z0-9-]{1,128}", subscription_id ): raise AccountError("invalid_request", "A valid Twitch subscription ID is required.") self._helix("DELETE", "/eventsub/subscriptions", access_token, params={"id": subscription_id}) return {"deleted": True} session_id = params.get("session_id") if not isinstance(session_id, str) or not re.fullmatch(r"[A-Za-z0-9_=-]{1,256}", session_id): raise AccountError("invalid_request", "A host-owned Twitch event session is required.") event_types = params.get("event_types", ["stream.online", "stream.offline"]) if ( not isinstance(event_types, (list, tuple)) or not event_types or len(event_types) > 4 or any(not isinstance(item, str) or item not in _EVENTS for item in event_types) ): raise AccountError("invalid_request", "Choose a supported Twitch event type.") # A user token may read a selected public channel. The reader remains # the authenticated subject in the chat condition below; channel # ownership is not required for these allowlisted public event types. if "channel.chat.message" in event_types and "user:read:chat" not in scopes: raise AccountError( "missing_scope", "Reconnect Twitch and allow reading chat messages.", status=403 ) subscriptions = [] for event_type in dict.fromkeys(event_types): version, condition_key = _EVENTS[event_type] condition = {condition_key: broadcaster} if event_type == "channel.chat.message": condition["user_id"] = subject rows = self._rows( self._helix( "POST", "/eventsub/subscriptions", access_token, json={ "type": event_type, "version": version, "condition": condition, "transport": {"method": "websocket", "session_id": session_id}, }, ) ) if not rows: raise AccountError( "provider_response", "Twitch did not confirm the event subscription.", status=502 ) subscriptions.append( { "id": _text(rows[0].get("id"), maximum=128), "type": event_type, "status": _text(rows[0].get("status"), maximum=100), } ) return {"subscriptions": subscriptions}