diff --git a/.gitignore b/.gitignore index 0a162d7..bd4ef2a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /.idea composer.lock .phpunit.result.cache +.twitch_token.json tmi.php diff --git a/composer.json b/composer.json index 5b1bef4..a4f8e14 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,8 @@ "require": { "php": "^7.4|^8.0", "react/socket": "^1.6", + "react/http": "^1.6", + "ratchet/pawl": "^0.4", "ext-mbstring": "*", "ext-json": "*" }, diff --git a/src/CommandContext.php b/src/CommandContext.php new file mode 100644 index 0000000..b20bdb1 --- /dev/null +++ b/src/CommandContext.php @@ -0,0 +1,43 @@ +event = $event; + $this->command = $command; + $this->args = $args; + $this->client = $client; + } + + /** + * Sends a Twitch reply thread to the triggering message. + */ + public function reply(string $message): void + { + $this->client->sendMessage($this->event->broadcasterUserId, $message, $this->event->messageId); + } + + /** + * Sends a regular chat message (no reply thread). + */ + public function say(string $message): void + { + $this->client->sendMessage($this->event->broadcasterUserId, $message); + } +} diff --git a/src/EventSubClient.php b/src/EventSubClient.php new file mode 100644 index 0000000..4a195df --- /dev/null +++ b/src/EventSubClient.php @@ -0,0 +1,240 @@ +options = $options; + $this->loop = \React\EventLoop\Factory::create(); + $this->eventHandler = new EventHandler(); + $this->browser = new Browser($this->loop); + } + + public function connect(): void + { + $this->connectWebSocket(self::WEBSOCKET_URL); + $this->loop->run(); + } + + public function on(string $event, callable $closure): self + { + $this->eventHandler->addHandler($event, $closure); + return $this; + } + + public function any(callable $closure): self + { + return $this->on('*', $closure); + } + + /** + * Registers a chat command handler. The command name must include the prefix (e.g. '!hello'). + * The handler receives a CommandContext with reply() and say() helpers. + */ + public function command(string $name, callable $handler): self + { + $this->commands[strtolower($name)] = $handler; + return $this; + } + + /** + * Sends a chat message via the Helix API. Pass a replyToMessageId to create a Twitch reply thread. + */ + public function sendMessage(string $broadcasterUserId, string $message, ?string $replyToMessageId = null): void + { + $body = [ + 'broadcaster_id' => $broadcasterUserId, + 'sender_id' => $this->options->getBotUserId(), + 'message' => $message, + ]; + + if ($replyToMessageId !== null) { + $body['reply_parent_message_id'] = $replyToMessageId; + } + + $this->browser->post(self::API_CHAT_MESSAGES_URL, [ + 'Authorization' => 'Bearer ' . $this->options->getAccessToken(), + 'Client-Id' => $this->options->getClientId(), + 'Content-Type' => 'application/json', + ], json_encode($body))->then( + function (\Psr\Http\Message\ResponseInterface $response) { + $this->debug("Chat message sent: HTTP {$response->getStatusCode()}"); + }, + function (\Exception $e) { + $this->debug("Failed to send chat message: " . $e->getMessage()); + } + ); + } + + public function getLoop(): LoopInterface + { + return $this->loop; + } + + public function getEventHandler(): EventHandler + { + return $this->eventHandler; + } + + private function connectWebSocket(string $url): void + { + \Ratchet\Client\connect($url, [], [], $this->loop)->then( + function (WebSocket $conn) { + $this->debug("Connected to EventSub WebSocket."); + + $conn->on('message', function (MessageInterface $msg) { + $payload = json_decode((string) $msg, true); + if ($payload !== null) { + $this->handleMessage($payload); + } + }); + + $conn->on('close', function (int $code, string $reason) { + $this->debug("WebSocket closed: {$code} {$reason}"); + if ($this->options->shouldReconnect()) { + $delay = $this->options->getReconnectDelay(); + $this->loop->addTimer($delay, fn () => $this->connectWebSocket(self::WEBSOCKET_URL)); + } + }); + + $conn->on('error', function (\Exception $e) { + $this->debug("WebSocket error: " . $e->getMessage()); + }); + }, + function (\Exception $e) { + $this->debug("Failed to connect: " . $e->getMessage()); + if ($this->options->shouldReconnect()) { + $delay = $this->options->getReconnectDelay(); + $this->loop->addTimer($delay, fn () => $this->connectWebSocket(self::WEBSOCKET_URL)); + } + } + ); + } + + private function handleMessage(array $payload): void + { + $this->debug(json_encode($payload)); + + $messageType = $payload['metadata']['message_type'] ?? null; + + switch ($messageType) { + case 'session_welcome': + $sessionId = $payload['payload']['session']['id']; + $this->subscribeToEvents($sessionId); + break; + + case 'notification': + $this->handleNotification($payload['payload']); + break; + + case 'session_reconnect': + $reconnectUrl = $payload['payload']['session']['reconnect_url']; + $this->debug("Reconnecting to: {$reconnectUrl}"); + $this->connectWebSocket($reconnectUrl); + break; + + case 'revocation': + $type = $payload['payload']['subscription']['type'] ?? 'unknown'; + $status = $payload['payload']['subscription']['status'] ?? 'unknown'; + $this->debug("Subscription revoked — type: {$type}, status: {$status}"); + break; + + case 'session_keepalive': + break; + } + } + + private function subscribeToEvents(string $sessionId): void + { + foreach ($this->options->getSubscriptions() as $subscription) { + $body = json_encode([ + 'type' => $subscription['type'], + 'version' => $subscription['version'] ?? '1', + 'condition' => $subscription['condition'], + 'transport' => [ + 'method' => 'websocket', + 'session_id' => $sessionId, + ], + ]); + + $this->browser->post(self::API_SUBSCRIPTIONS_URL, [ + 'Authorization' => 'Bearer ' . $this->options->getAccessToken(), + 'Client-Id' => $this->options->getClientId(), + 'Content-Type' => 'application/json', + ], $body)->then( + function (\Psr\Http\Message\ResponseInterface $response) use ($subscription) { + $this->debug("Subscribed to {$subscription['type']}: HTTP {$response->getStatusCode()}"); + }, + function (\Exception $e) use ($subscription) { + $this->debug("Failed to subscribe to {$subscription['type']}: " . $e->getMessage()); + } + ); + } + } + + private function handleNotification(array $payload): void + { + $subscriptionType = $payload['subscription']['type'] ?? null; + $eventData = $payload['event'] ?? []; + + $event = match ($subscriptionType) { + 'channel.chat.message' => new ChatMessageEvent($eventData), + 'channel.chat.notification' => new ChatNotificationEvent($eventData), + default => null, + }; + + if ($event !== null) { + $this->eventHandler->invoke($event); + + if ($event instanceof ChatMessageEvent) { + $this->parseCommand($event); + } + } + } + + private function parseCommand(ChatMessageEvent $event): void + { + $text = trim($event->messageText); + + if ($text === '' || $text[0] !== '!') { + return; + } + + $parts = explode(' ', $text, 2); + $name = strtolower($parts[0]); + $args = isset($parts[1]) ? explode(' ', trim($parts[1])) : []; + + if (!isset($this->commands[$name])) { + return; + } + + ($this->commands[$name])(new CommandContext($event, $name, $args, $this)); + } + + private function debug(string $message): void + { + if ($this->options->isDebug()) { + print $message . PHP_EOL; + } + } +} diff --git a/src/EventSubOptions.php b/src/EventSubOptions.php new file mode 100644 index 0000000..8a79db5 --- /dev/null +++ b/src/EventSubOptions.php @@ -0,0 +1,73 @@ +options = $options; + } + + public function getAccessToken(): string + { + return ltrim($this->options['access_token'], 'oauth:'); + } + + public function getClientId(): string + { + return $this->options['client_id']; + } + + /** + * Returns the list of EventSub subscriptions to register. + * + * Each entry must have: + * - type: e.g. 'channel.chat.message' + * - version: (optional, defaults to '1') + * - condition: associative array of condition fields + * + * Example: + * ['type' => 'channel.chat.message', 'condition' => ['broadcaster_user_id' => '123', 'user_id' => '456']] + */ + public function getSubscriptions(): array + { + return $this->options['subscriptions'] ?? []; + } + + /** + * Returns the bot's Twitch user ID used as the sender when posting chat messages. + * Reads from the top-level 'user_id' option, falling back to the first subscription's condition. + */ + public function getBotUserId(): string + { + if (isset($this->options['user_id'])) { + return $this->options['user_id']; + } + + foreach ($this->options['subscriptions'] ?? [] as $sub) { + if (isset($sub['condition']['user_id'])) { + return $sub['condition']['user_id']; + } + } + + throw new \RuntimeException('Bot user ID not configured. Add "user_id" to EventSubOptions or include it in a subscription condition.'); + } + + public function isDebug(): bool + { + return $this->options['options']['debug'] ?? false; + } + + public function shouldReconnect(): bool + { + return $this->options['connection']['reconnect'] ?? true; + } + + public function getReconnectDelay(): int + { + return $this->options['connection']['reconnect_delay'] ?? 3; + } +} diff --git a/src/Events/EventSub/ChatMessageEvent.php b/src/Events/EventSub/ChatMessageEvent.php new file mode 100644 index 0000000..938d2cc --- /dev/null +++ b/src/Events/EventSub/ChatMessageEvent.php @@ -0,0 +1,51 @@ +broadcasterUserId = $event['broadcaster_user_id']; + $this->broadcasterUserLogin = $event['broadcaster_user_login']; + $this->broadcasterUserName = $event['broadcaster_user_name']; + $this->chatterUserId = $event['chatter_user_id']; + $this->chatterUserLogin = $event['chatter_user_login']; + $this->chatterUserName = $event['chatter_user_name']; + $this->messageId = $event['message_id']; + $this->messageText = $event['message']['text']; + $this->fragments = $event['message']['fragments'] ?? []; + $this->messageType = $event['message_type']; + $this->color = $event['color'] ?? ''; + $this->badges = $event['badges'] ?? []; + $this->cheer = $event['cheer'] ?? null; + $this->reply = $event['reply'] ?? null; + $this->channelPointsCustomRewardId = $event['channel_points_custom_reward_id'] ?? null; + $this->sourceBroadcasterUserId = $event['source_broadcaster_user_id'] ?? null; + $this->sourceBroadcasterUserLogin = $event['source_broadcaster_user_login'] ?? null; + $this->sourceBroadcasterUserName = $event['source_broadcaster_user_name'] ?? null; + $this->isSourceOnly = $event['is_source_only'] ?? false; + } +} diff --git a/src/Events/EventSub/ChatNotificationEvent.php b/src/Events/EventSub/ChatNotificationEvent.php new file mode 100644 index 0000000..fe94d2c --- /dev/null +++ b/src/Events/EventSub/ChatNotificationEvent.php @@ -0,0 +1,58 @@ +broadcasterUserId = $event['broadcaster_user_id']; + $this->broadcasterUserLogin = $event['broadcaster_user_login']; + $this->broadcasterUserName = $event['broadcaster_user_name']; + $this->chatterUserId = $event['chatter_user_id']; + $this->chatterUserLogin = $event['chatter_user_login']; + $this->chatterUserName = $event['chatter_user_name']; + $this->chatterIsAnonymous = $event['chatter_is_anonymous'] ?? false; + $this->color = $event['color'] ?? ''; + $this->badges = $event['badges'] ?? []; + $this->systemMessage = $event['system_message'] ?? ''; + $this->messageId = $event['message_id']; + $this->messageText = $event['message']['text'] ?? ''; + $this->fragments = $event['message']['fragments'] ?? []; + $this->noticeType = $event['notice_type']; + $this->payload = $event; + } + + public function getNoticeData(): ?array + { + return $this->payload[$this->noticeType] ?? null; + } +} diff --git a/src/OAuthHelper.php b/src/OAuthHelper.php new file mode 100644 index 0000000..3967452 --- /dev/null +++ b/src/OAuthHelper.php @@ -0,0 +1,289 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->port = $port; + $this->tokenFile = $tokenFile; + } + + /** + * Returns a valid access token, running the OAuth flow when needed. + * + * @param string[] $scopes Required OAuth scopes. + */ + public function getAccessToken(array $scopes = ['user:read:chat']): string + { + $token = $this->loadToken(); + + if ($token !== null && $this->scopesChanged($token, $scopes)) { + $token = null; + } + + if ($token !== null && !$this->isTokenExpired($token)) { + return $token['access_token']; + } + + if ($token !== null && isset($token['refresh_token'])) { + try { + $refreshed = $this->exchangeToken([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $token['refresh_token'], + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ]); + $this->saveToken($refreshed); + return $refreshed['access_token']; + } catch (\Exception $e) { + // Fall through to full flow + } + } + + $token = $this->runOAuthFlow($scopes); + $this->saveToken($token); + return $token['access_token']; + } + + /** + * Returns the Twitch user ID for the authenticated account, or null if not yet authorized. + */ + public function getUserId(): ?string + { + return $this->loadToken()['user_id'] ?? null; + } + + /** + * Returns the Twitch login name for the authenticated account, or null if not yet authorized. + */ + public function getLogin(): ?string + { + return $this->loadToken()['login'] ?? null; + } + + private function runOAuthFlow(array $scopes): array + { + $loop = Factory::create(); + $state = bin2hex(random_bytes(16)); + $redirectUri = "http://localhost:{$this->port}/callback"; + + $authUrl = self::AUTH_URL . '?' . http_build_query([ + 'client_id' => $this->clientId, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'scope' => implode(' ', $scopes), + 'state' => $state, + 'force_verify' => 'true', + ]); + + $browser = new Browser($loop); + $socket = new SocketServer("127.0.0.1:{$this->port}", $loop); + + $tokenData = null; + $flowError = null; + + $httpServer = new HttpServer( + function (ServerRequestInterface $request) use ( + $state, $redirectUri, $browser, $socket, $loop, &$tokenData, &$flowError + ) { + parse_str($request->getUri()->getQuery(), $params); + + if (($params['state'] ?? '') !== $state) { + return new Response(400, ['Content-Type' => 'text/plain'], 'Invalid state parameter.'); + } + + if (isset($params['error'])) { + $flowError = $params['error_description'] ?? $params['error']; + $socket->close(); + $loop->stop(); + return new Response(400, ['Content-Type' => 'text/plain'], "Authorization denied: {$flowError}"); + } + + $browser->post(self::TOKEN_URL, [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], http_build_query([ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $params['code'], + 'grant_type' => 'authorization_code', + 'redirect_uri' => $redirectUri, + ]))->then( + function ($response) use ($browser, $socket, $loop, &$tokenData, &$flowError) { + $data = json_decode((string) $response->getBody(), true); + + if (!isset($data['access_token'])) { + $flowError = 'Token exchange failed: ' . (string) $response->getBody(); + $socket->close(); + $loop->stop(); + return; + } + + // Fetch user info so caller knows broadcaster_user_id / user_id + $browser->get(self::VALIDATE_URL, [ + 'Authorization' => 'OAuth ' . $data['access_token'], + ])->then( + function ($vResponse) use ($data, $socket, $loop, &$tokenData) { + $userInfo = json_decode((string) $vResponse->getBody(), true); + $tokenData = array_merge($data, [ + 'expires_at' => time() + ($data['expires_in'] ?? 3600), + 'user_id' => $userInfo['user_id'] ?? null, + 'login' => $userInfo['login'] ?? null, + ]); + $socket->close(); + $loop->stop(); + }, + function () use ($data, $socket, $loop, &$tokenData) { + $tokenData = array_merge($data, [ + 'expires_at' => time() + ($data['expires_in'] ?? 3600), + ]); + $socket->close(); + $loop->stop(); + } + ); + }, + function (\Exception $e) use ($socket, $loop, &$flowError) { + $flowError = $e->getMessage(); + $socket->close(); + $loop->stop(); + } + ); + + return new Response( + 200, + ['Content-Type' => 'text/html'], + '
' . + 'You can close this window and return to the terminal.
' . + '' + ); + } + ); + + $httpServer->listen($socket); + + echo PHP_EOL . "Opening Twitch authorization page in your browser..." . PHP_EOL; + echo "If it does not open automatically, visit:" . PHP_EOL . PHP_EOL; + echo " {$authUrl}" . PHP_EOL . PHP_EOL; + echo "Waiting for authorization on http://localhost:{$this->port}/callback ..." . PHP_EOL; + + $this->openBrowser($authUrl); + + $loop->run(); + + if ($flowError !== null) { + throw new \RuntimeException("OAuth flow failed: {$flowError}"); + } + + if (!isset($tokenData['access_token'])) { + throw new \RuntimeException('OAuth flow failed: no access token received.'); + } + + echo "Authorized as: " . ($tokenData['login'] ?? 'unknown') . + " (user_id: " . ($tokenData['user_id'] ?? 'unknown') . ")" . PHP_EOL . PHP_EOL; + + return $tokenData; + } + + private function exchangeToken(array $params): array + { + $loop = Factory::create(); + $browser = new Browser($loop); + + $result = null; + $error = null; + + $browser->post(self::TOKEN_URL, [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], http_build_query($params))->then( + function ($response) use (&$result, $loop) { + $data = json_decode((string) $response->getBody(), true); + if (isset($data['access_token'])) { + $data['expires_at'] = time() + ($data['expires_in'] ?? 3600); + $result = $data; + } + $loop->stop(); + }, + function (\Exception $e) use (&$error, $loop) { + $error = $e->getMessage(); + $loop->stop(); + } + ); + + $loop->run(); + + if ($error !== null || $result === null) { + throw new \RuntimeException("Token exchange failed: " . ($error ?? 'empty response')); + } + + return $result; + } + + private function openBrowser(string $url): void + { + $escaped = escapeshellarg($url); + match (PHP_OS_FAMILY) { + 'Windows' => exec("start {$escaped}"), + 'Darwin' => exec("open {$escaped}"), + default => exec("xdg-open {$escaped} 2>/dev/null &"), + }; + } + + private function loadToken(): ?array + { + if (!file_exists($this->tokenFile)) { + return null; + } + + $data = json_decode(file_get_contents($this->tokenFile), true); + return is_array($data) ? $data : null; + } + + private function saveToken(array $tokenData): void + { + file_put_contents($this->tokenFile, json_encode($tokenData, JSON_PRETTY_PRINT)); + } + + private function isTokenExpired(array $tokenData): bool + { + if (!isset($tokenData['expires_at'])) { + return false; + } + return time() >= ($tokenData['expires_at'] - 60); + } + + private function scopesChanged(array $token, array $requestedScopes): bool + { + $stored = $token['scope'] ?? []; + if (is_string($stored)) { + $stored = explode(' ', $stored); + } + sort($stored); + sort($requestedScopes); + return $stored !== $requestedScopes; + } +}