diff --git a/src/App.tsx b/src/App.tsx index 054493e..9bbdd57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,24 @@ import { useContext } from "react"; -import "./App.css"; import { AuthStateContext } from "./contexts/auth-state/AuthStateContext"; import LoginButton from "./components/LoginButton"; +import "./App.css"; +import { useEventSub } from "./hooks/useEventSub"; export default function App() { - const context = useContext(AuthStateContext); + const authContext = useContext(AuthStateContext); + const { lastMessage } = useEventSub("channel.chat.message", { + broadcaster_user_id: authContext?.authState?.user.id, + user_id: authContext?.authState?.user.id, + }); - if (!context) { + if (!authContext) { return

Missing AuthStateContext provider?

; } return ( <> -

Hello: {context.authState?.user.login}

+

Hello: {authContext.authState?.user.login}

+

Last message: {JSON.stringify(lastMessage)}

); diff --git a/src/contexts/event-sub/EventSubContext.ts b/src/contexts/event-sub/EventSubContext.ts new file mode 100644 index 0000000..a97fe6c --- /dev/null +++ b/src/contexts/event-sub/EventSubContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { EventSub } from "../../utils/event-sub/EventSub"; + +interface Value { + subscribe: typeof EventSub.instance.subscribe; +} + +export const EventSubContext = createContext(null); diff --git a/src/contexts/event-sub/EventSubProvider.tsx b/src/contexts/event-sub/EventSubProvider.tsx new file mode 100644 index 0000000..dc7881e --- /dev/null +++ b/src/contexts/event-sub/EventSubProvider.tsx @@ -0,0 +1,18 @@ +import { EventSub } from "../../utils/event-sub/EventSub"; +import { EventSubContext } from "./EventSubContext"; + +interface Props { + children: React.ReactNode; +} + +export default function EventSubProvider({ + children, +}: Props): React.ReactElement { + const subscribe = EventSub.instance.subscribe.bind(EventSub.instance); + + return ( + + {children} + + ); +} diff --git a/src/hooks/useEventSub.ts b/src/hooks/useEventSub.ts new file mode 100644 index 0000000..0c80878 --- /dev/null +++ b/src/hooks/useEventSub.ts @@ -0,0 +1,52 @@ +import { useCallback, useContext, useEffect, useState } from "react"; +import { EventSubContext } from "../contexts/event-sub/EventSubContext"; +import { AuthStateContext } from "../contexts/auth-state/AuthStateContext"; + +export function useEventSub( + type: string | string[], + condition: Record, + bufferSize = 50 +) { + const authStateContext = useContext(AuthStateContext); + const eventSubContext = useContext(EventSubContext); + const [messages, setMessages] = useState[]>([]); + const [lastMessage, setLastMessage] = useState>(); + + if (!authStateContext) { + throw new Error("useEventSub: Needs an AuthStateContext Provider!"); + } + + if (!eventSubContext) { + throw new Error("useEventSub: Needs a EventSubContext Provider!"); + } + + const { subscribe } = eventSubContext; + + const handleMessage = useCallback( + (message: Record) => { + setMessages((messages) => [...messages.slice(-bufferSize + 1), message]); + setLastMessage(() => message); + }, + [bufferSize] + ); + + useEffect(() => { + const { authState } = authStateContext; + + if (!authState) { + return; + } + + if (Object.values(condition).some((value) => value === undefined)) { + console.error( + "useEventSub: Was given a bad subscription condition:", + condition + ); + return; + } + + return subscribe(authState, type, condition, handleMessage); + }, [authStateContext, condition, handleMessage, subscribe, type]); + + return { lastMessage, messages }; +} diff --git a/src/main.tsx b/src/main.tsx index de31ef7..cdedc3c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; import AuthStateProvider from "./contexts/auth-state/AuthStateProvider.tsx"; +import EventSubProvider from "./contexts/event-sub/EventSubProvider.tsx"; const rootElement = document.querySelector("#root"); @@ -13,7 +14,9 @@ if (!(rootElement instanceof HTMLDivElement)) { createRoot(rootElement).render( - + + + ); diff --git a/src/utils/api/event-sub/subscribe.ts b/src/utils/api/event-sub/subscribe.ts new file mode 100644 index 0000000..a577d1c --- /dev/null +++ b/src/utils/api/event-sub/subscribe.ts @@ -0,0 +1,65 @@ +import type { AuthState } from "../../../contexts/auth-state/AuthStateContext"; + +interface SubscriptionsResponse { + data: [ + { + id: string; + status: "enabled"; + type: string; + version: string; + condition: Record; + /** RFC3339 Date Time */ + created_at: string; + transport: { + method: "websocket"; + session_id: string; + }; + connected_at: string; + cost: number; + } + ]; + total: number; + total_cost: number; + max_total_cost: number; +} + +export async function subscribe( + authState: AuthState, + sessionId: string, + type: string, + version: string, + condition: Record +): Promise { + const response = await fetch( + "https://api.twitch.tv/helix/eventsub/subscriptions", + { + method: "POST", + headers: { + Authorization: `Bearer ${authState.token.value}`, + "Client-Id": authState.client.id, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type, + version, + condition, + transport: { + method: "websocket", + session_id: sessionId, + }, + }), + } + ); + + if (!response.ok) { + throw new Error( + `subscriptions: Bad HTTP response ${response.status.toString()} ${ + response.statusText + }` + ); + } + + const result = (await response.json()) as SubscriptionsResponse; + + return result; +} diff --git a/src/utils/api/event-sub/unsubscribe.ts b/src/utils/api/event-sub/unsubscribe.ts new file mode 100644 index 0000000..ba59c36 --- /dev/null +++ b/src/utils/api/event-sub/unsubscribe.ts @@ -0,0 +1,25 @@ +import type { AuthState } from "../../../contexts/auth-state/AuthStateContext"; + +export async function unsubscribe( + authState: AuthState, + subscriptionId: string +): Promise { + const url = new URL("https://api.twitch.tv/helix/eventsub/subscriptions"); + url.searchParams.set("id", subscriptionId); + + const response = await fetch(url, { + method: "DELETE", + headers: { + Authorization: `Bearer ${authState.token.value}`, + "Client-Id": authState.client.id, + }, + }); + + if (!response.ok) { + throw new Error( + `subscriptions: Bad HTTP response ${response.status.toString()} ${ + response.statusText + }` + ); + } +} diff --git a/src/utils/event-sub/EventSub.ts b/src/utils/event-sub/EventSub.ts new file mode 100644 index 0000000..42cfcfd --- /dev/null +++ b/src/utils/event-sub/EventSub.ts @@ -0,0 +1,181 @@ +import type { AuthState } from "../../contexts/auth-state/AuthStateContext"; +import { subscribe } from "../api/event-sub/subscribe"; +import type { + EventSubMessage, + KeepaliveMessage, + NotificationMessage, + WelcomeMessage, +} from "./events/websocket"; + +const EVENTSUB_WEBSOCKET_URI = "wss://eventsub.wss.twitch.tv/ws"; + +const versions: Record = { + "channel.chat.clear_user_messages": "1", + "channel.chat.clear": "1", + "channel.chat.message": "1", + "channel.chat.message_delete": "1", +} as const; + +type Callback = (event: Record) => void; + +function isMessageType( + message: EventSubMessage, + messageType: Message["metadata"]["message_type"] +): message is Message { + return message.metadata.message_type === messageType; +} + +export class EventSub { + private static _instance: EventSub | null = null; + private ws; + private listeners: Map>; + private _sessionId?: string; + private _awaitingResolvers: ((value: string) => void)[] = []; + + private constructor() { + EventSub._instance = this; + this.ws = new WebSocket(EVENTSUB_WEBSOCKET_URI); + this.listeners = new Map>(); + + this.ws.addEventListener("open", (event) => { + console.debug("WebSocket: Open:", event); + }); + + this.ws.addEventListener("close", (event) => { + console.debug("WebSocket: Close:", event); + }); + + this.ws.addEventListener("message", (event: MessageEvent) => { + const message = JSON.parse(event.data) as EventSubMessage; + + this.handleMessage(message); + }); + } + + public static get instance(): EventSub { + if (!EventSub._instance) { + return new EventSub(); + } + + return EventSub._instance; + } + + set sessionId(id: string) { + this._sessionId = id; + + if (this._awaitingResolvers.length > 0) { + this._awaitingResolvers.forEach((resolver) => { + resolver(id); + }); + + this._awaitingResolvers.length = 0; + } + } + + async getSessionId(): Promise { + if (this._sessionId) { + return this._sessionId; + } + + const promise = new Promise((resolve) => { + // TODO: Deal with a reject promise too + this._awaitingResolvers.push(resolve); + }); + + return promise; + } + + private handleMessage(message: EventSubMessage) { + if (isMessageType(message, "session_welcome")) { + console.debug("WebSocket: Welcome Message:", message); + this.handleWelcomeMessage(message); + } else if (isMessageType(message, "session_keepalive")) { + console.debug( + "WebSocket: Keepalive:", + message.metadata.message_timestamp + ); + } else if (isMessageType(message, "notification")) { + console.debug("WebSocket: Notification Message:", message); + this.handleNotificationMessage(message); + } + } + + private handleWelcomeMessage(message: WelcomeMessage) { + this.sessionId = message.payload.session.id; + } + + private handleNotificationMessage(message: NotificationMessage) { + const messageType = message.payload.subscription.type; + + if (!this.listeners.has(messageType)) { + // No one is listening for this event? + console.debug( + "WebSocket: No Listeners for:", + messageType, + ", message dropped." + ); + return; + } + + const event = { + type: messageType, + ...message.payload.event, + }; + + this.listeners.get(messageType)?.forEach((listener) => { + listener(event); + }); + } + + private async addSubscription( + authState: AuthState, + type: string, + condition: Record + ): Promise { + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + + void subscribe( + authState, + await this.getSessionId(), + type, + versions[type], + condition + ).catch((error: unknown) => { + console.error( + "addSubscription: failed to add subscription for:", + type, + "Got error:", + error + ); + }); + } + } + + // FIXME: These unsubscribe functions doesn't unsubscribe from EventSub + // if they're the last listener + subscribe( + authState: AuthState, + type: string | string[], + condition: Record, + callback: Callback + ): () => void { + if (Array.isArray(type)) { + type.forEach((type) => { + void this.addSubscription(authState, type, condition); + this.listeners.get(type)?.add(callback); + }); + + return () => { + type.forEach((type) => this.listeners.get(type)?.delete(callback)); + }; + } else { + void this.addSubscription(authState, type, condition); + this.listeners.get(type)?.add(callback); + + return () => { + this.listeners.get(type)?.delete(callback); + }; + } + } +} diff --git a/src/utils/event-sub/events/chat/_common.ts b/src/utils/event-sub/events/chat/_common.ts new file mode 100644 index 0000000..4001ec2 --- /dev/null +++ b/src/utils/event-sub/events/chat/_common.ts @@ -0,0 +1,5 @@ +export interface ChatEventCommon { + broadcaster_user_id: string; + broadcaster_user_name: string; + broadcaster_user_login: string; +} diff --git a/src/utils/event-sub/events/chat/clear.ts b/src/utils/event-sub/events/chat/clear.ts new file mode 100644 index 0000000..1eb0dfb --- /dev/null +++ b/src/utils/event-sub/events/chat/clear.ts @@ -0,0 +1,19 @@ +import type { ChatEventCommon } from "./_common"; +import type { EventSubTransport } from "../transport"; + +export interface ChatClearPayload { + subscription: { + id: string; + type: "channel.chat.clear"; + version: "1"; + status: "enabled"; + cost: number; + condition: { + broadcaster_user_id: string; + user_id: string; + }; + transport: EventSubTransport; + created_at: string; + }; + event: ChatEventCommon; +} diff --git a/src/utils/event-sub/events/chat/clearUser.ts b/src/utils/event-sub/events/chat/clearUser.ts new file mode 100644 index 0000000..5e9a253 --- /dev/null +++ b/src/utils/event-sub/events/chat/clearUser.ts @@ -0,0 +1,25 @@ +import type { ChatEventCommon } from "./_common"; +import type { EventSubTransport } from "../transport"; + +export interface ChatClearUserMessagePayload { + subscription: { + id: string; + type: "channel.chat.clear_user_messages"; + version: "1"; + status: "enabled"; + cost: number; + condition: { + broadcaster_user_id: string; + user_id: string; + }; + transport: EventSubTransport; + created_at: string; + }; + event: ChatClearUserMessageEvent; +} + +interface ChatClearUserMessageEvent extends ChatEventCommon { + target_user_id: string; + target_user_name: string; + target_user_login: string; +} diff --git a/src/utils/event-sub/events/chat/message.ts b/src/utils/event-sub/events/chat/message.ts new file mode 100644 index 0000000..0b58798 --- /dev/null +++ b/src/utils/event-sub/events/chat/message.ts @@ -0,0 +1,105 @@ +import type { ChatEventCommon } from "./_common"; +import type { EventSubTransport } from "../transport"; + +export interface ChatMessagePayload { + subscription: { + id: string; + type: "channel.chat.message"; + version: "1"; + status: "enabled"; + cost: number; + condition: { + broadcaster_user_id: string; + user_id: string; + }; + transport: EventSubTransport; + created_at: string; + }; + event: ChatMessageEvent; +} + +interface ChatMessageEvent extends ChatEventCommon { + chatter_user_id: string; + chatter_user_name: string; + chatter_user_login: string; + /** Message UUID */ + message_id: string; + message: { + /** Chat message in plain text */ + text: string; + fragments: ChatFragment[]; + }; + message_type: + | "text" + | "channel_points_highlighted" + | "channel_points_sub_only" + | "user_intro" + | "power_ups_message_effect" + | "power_ups_gigantified_emote"; + badges: { + set_id: string; + id: string; + /** Months subscribed */ + info: string; + }[]; + cheer?: { + bits: number; + }; + color: string; + reply?: { + parent_message_id: string; + parent_message_body: string; + parent_user_id: string; + parent_user_name: string; + parent_user_login: string; + thread_message_id: string; + thread_user_id: string; + thread_user_name: string; + thread_user_login: string; + }; + channel_points_custom_reward_id?: string; + channel_points_animation_id?: string; +} + +export type ChatFragment = + | TextFragment + | CheermoteFragment + | EmoteFragment + | MentionFragment; + +export interface TextFragment { + type: "text"; + text: string; +} + +export interface CheermoteFragment { + type: "cheermote"; + /** `${prefix}${bits}` */ + text: string; + cheermote: { + prefix: string; + bits: number; + tier: number; + }; +} + +export interface EmoteFragment { + type: "emote"; + text: string; + emote: { + id: string; + emote_set_id: string; + owner_id: string; + format: ["static"] | ["static", "animated"]; // Tuples might be bad idea? + }; +} + +export interface MentionFragment { + type: "mention"; + text: string; + mention: { + user_id: string; + user_name: string; + user_login: string; + }; +} diff --git a/src/utils/event-sub/events/chat/messageDelete.ts b/src/utils/event-sub/events/chat/messageDelete.ts new file mode 100644 index 0000000..a432dbe --- /dev/null +++ b/src/utils/event-sub/events/chat/messageDelete.ts @@ -0,0 +1,26 @@ +import type { ChatEventCommon } from "./_common"; +import type { EventSubTransport } from "../transport"; + +export interface ChatMessageDeletePayload { + subscription: { + id: string; + type: "channel.chat.message_delete"; + version: "1"; + status: "enabled"; + cost: number; + condition: { + broadcaster_user_id: string; + user_id: string; + }; + transport: EventSubTransport; + created_at: string; + }; + event: ChatMessageDeleteEvent; +} + +interface ChatMessageDeleteEvent extends ChatEventCommon { + target_user_id: string; + target_user_name: string; + target_user_login: string; + message_id: string; +} diff --git a/src/utils/event-sub/events/transport.ts b/src/utils/event-sub/events/transport.ts new file mode 100644 index 0000000..97865e9 --- /dev/null +++ b/src/utils/event-sub/events/transport.ts @@ -0,0 +1,4 @@ +export interface EventSubTransport { + method: string; + session_id: string; +} diff --git a/src/utils/event-sub/events/websocket.ts b/src/utils/event-sub/events/websocket.ts new file mode 100644 index 0000000..ff67cf1 --- /dev/null +++ b/src/utils/event-sub/events/websocket.ts @@ -0,0 +1,52 @@ +import type { ChatClearPayload } from "./chat/clear"; +import type { ChatClearUserMessagePayload } from "./chat/clearUser"; +import type { ChatMessagePayload } from "./chat/message"; +import type { ChatMessageDeletePayload } from "./chat/messageDelete"; + +export type EventSubMessage = + | WelcomeMessage + | KeepaliveMessage + | NotificationMessage; + +type NotificationPayload = + | ChatClearPayload + | ChatClearUserMessagePayload + | ChatMessagePayload + | ChatMessageDeletePayload; + +export interface WelcomeMessage { + metadata: { + message_id: string; + message_type: "session_welcome"; + message_timestamp: string; // UTC date and time + }; + payload: { + session: { + id: string; + status: string; + connected_at: string; // UTC date and time + keepalive_timeout_seconds: number; + reconnect_url: string | null; + }; + }; +} + +export interface KeepaliveMessage { + metadata: { + message_id: string; + message_type: "session_keepalive"; + message_timestamp: string; + }; + payload: Record; +} + +export interface NotificationMessage { + metadata: { + message_id: string; // Unique message identifier + message_type: "notification"; + message_timestamp: string; // UTC date for when message sent + subscription_type: string; // Type of event sent in the message + subscription_version: string; // Version number for the subscription's definition + }; + payload: NotificationPayload; +}