From e10222af705e3475fcea6e0b17d1c9984a62db26 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 6 Oct 2023 16:22:57 +0200 Subject: [PATCH] basic twitch join/part commands --- app/Http/Controllers/ChannelController.php | 67 +++++++++ app/Models/Channel.php | 5 + app/Models/TwitchBotCommand.php | 89 ++++++++++++ app/Policies/ChannelPolicy.php | 2 +- app/TwitchBot/IRCMessage.php | 7 + app/TwitchBot/TwitchBot.php | 25 +++- app/TwitchBotCommands/BaseCommand.php | 50 +++++++ app/TwitchBotCommands/JoinCommand.php | 23 ++++ app/TwitchBotCommands/PartCommand.php | 23 ++++ ...45919_add_join_field_to_channels_table.php | 32 +++++ ...20722_create_twitch_bot_commands_table.php | 38 ++++++ .../js/components/common/ChannelSelect.js | 129 ++++++++++++++++++ .../js/components/twitch-bot/Controls.js | 71 +++++++++- resources/js/helpers/permissions.js | 8 +- resources/js/i18n/de.js | 7 + resources/js/i18n/en.js | 7 + resources/sass/episodes.scss | 23 ++++ routes/api.php | 5 + 18 files changed, 603 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/ChannelController.php create mode 100644 app/Models/TwitchBotCommand.php create mode 100644 app/TwitchBotCommands/BaseCommand.php create mode 100644 app/TwitchBotCommands/JoinCommand.php create mode 100644 app/TwitchBotCommands/PartCommand.php create mode 100644 database/migrations/2023_10_04_145919_add_join_field_to_channels_table.php create mode 100644 database/migrations/2023_10_06_120722_create_twitch_bot_commands_table.php create mode 100644 resources/js/components/common/ChannelSelect.js diff --git a/app/Http/Controllers/ChannelController.php b/app/Http/Controllers/ChannelController.php new file mode 100644 index 0000000..904cb8f --- /dev/null +++ b/app/Http/Controllers/ChannelController.php @@ -0,0 +1,67 @@ +validate([ + 'joinable' => 'boolean|nullable', + 'manageable' => 'boolean|nullable', + 'phrase' => 'string|nullable', + ]); + + $channels = Channel::query(); + if (isset($validatedData['joinable']) && $validatedData['joinable']) { + $channels = $channels->where('twitch_chat', '!=', ''); + } + if (isset($validatedData['manageable']) && $validatedData['manageable']) { + $user = $request->user(); + if (!$user) { + return []; + } + $channels = $channels->whereHas('crews', function (Builder $query) use ($user) { + $query->where('user_id', '=', $user->id); + }); + } + if (!empty($validatedData['phrase'])) { + $channels = $channels->where('title', 'LIKE', '%'.$validatedData['phrase'].'%') + ->orWhere('short_name', 'LIKE', '%'.$validatedData['phrase'].'%'); + } + $channels = $channels->limit(5); + return $channels->get()->toJson(); + } + + public function single(Request $request, Channel $channel) { + $this->authorize('view', $channel); + return $channel->toJson(); + } + + public function join(Request $request, Channel $channel) { + if (!$channel->twitch_chat) { + throw new \Exception('channel has no twitch chat set'); + } + $this->authorize('editRestream', $channel); + $channel->join = true; + $channel->save(); + TwitchBotCommand::join($channel->twitch_chat); + return $channel->toJson(); + } + + public function part(Request $request, Channel $channel) { + if (!$channel->twitch_chat) { + throw new \Exception('channel has no twitch chat set'); + } + $this->authorize('editRestream', $channel); + $channel->join = false; + $channel->save(); + TwitchBotCommand::part($channel->twitch_chat); + return $channel->toJson(); + } + +} diff --git a/app/Models/Channel.php b/app/Models/Channel.php index 0536aef..025bb34 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -16,6 +16,10 @@ class Channel extends Model ->first(); } + public function crews() { + return $this->hasMany(ChannelCrew::class); + } + public function episodes() { return $this->belongsToMany(Episode::class) ->using(Restream::class) @@ -29,6 +33,7 @@ class Channel extends Model protected $casts = [ 'chat_commands' => 'array', 'languages' => 'array', + 'join' => 'boolean', ]; protected $hidden = [ diff --git a/app/Models/TwitchBotCommand.php b/app/Models/TwitchBotCommand.php new file mode 100644 index 0000000..f2b35e3 --- /dev/null +++ b/app/Models/TwitchBotCommand.php @@ -0,0 +1,89 @@ +command = 'join'; + $cmd->parameters = [ + 'channel' => $channel, + ]; + $cmd->status = 'pending'; + $cmd->save(); + } + + public static function part($channel) { + $cmd = new TwitchBotCommand(); + $cmd->command = 'part'; + $cmd->parameters = [ + 'channel' => $channel, + ]; + $cmd->status = 'pending'; + $cmd->save(); + } + + public function tournament() { + return $this->belongsTo(Tournament::class); + } + + public function user() { + return $this->belongsTo(User::class); + } + + public function execute(TwitchBot $bot) { + $this->setExecuting(); + + try { + BaseCommand::resolve($bot, $this) + ->execute() + ->otherwise(function (\Throwable $e) { + $this->setException($e); + }) + ->done(function($v = null) { + $this->setDone(); + }); + } catch (\Exception $e) { + $this->setException($e); + } + } + + + private function setDone() { + $this->status = 'done'; + $this->save(); + } + + private function setExecuting() { + $this->status = 'executing'; + $this->executed_at = now(); + $this->save(); + } + + private function setException(\Throwable $e) { + $this->status = 'exception'; + $this->result = [ + 'type' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'message' => $e->getMessage(), + ]; + $this->save(); + } + + + protected $casts = [ + 'parameters' => 'array', + 'result' => 'array', + 'executed_at' => 'datetime', + ]; + +} diff --git a/app/Policies/ChannelPolicy.php b/app/Policies/ChannelPolicy.php index 3e42c9f..21dc14b 100644 --- a/app/Policies/ChannelPolicy.php +++ b/app/Policies/ChannelPolicy.php @@ -30,7 +30,7 @@ class ChannelPolicy */ public function view(User $user, Channel $channel) { - return $channel->event->visible; + return true; } /** diff --git a/app/TwitchBot/IRCMessage.php b/app/TwitchBot/IRCMessage.php index 00217dc..c730ef7 100644 --- a/app/TwitchBot/IRCMessage.php +++ b/app/TwitchBot/IRCMessage.php @@ -132,6 +132,13 @@ class IRCMessage { return $msg; } + public static function part($channels) { + $msg = new IRCMessage(); + $msg->command = 'PART'; + $msg->params[] = implode(',', $channels); + return $msg; + } + public static function privmsg($target, $message) { $msg = new IRCMessage(); $msg->command = 'PRIVMSG'; diff --git a/app/TwitchBot/TwitchBot.php b/app/TwitchBot/TwitchBot.php index 3cba5d9..ddc51fe 100644 --- a/app/TwitchBot/TwitchBot.php +++ b/app/TwitchBot/TwitchBot.php @@ -3,6 +3,7 @@ namespace App\TwitchBot; use App\Models\Channel; +use App\Models\TwitchBotCommand; use App\Models\TwitchToken; use Monolog\Handler\StreamHandler; use Monolog\Logger; @@ -24,6 +25,7 @@ class TwitchBot { $this->connector = new Connector(); $this->connect(); + $this->listenCommands(); } public function getLogger() { @@ -75,12 +77,13 @@ class TwitchBot { public function handleWsMessage(Message $message, WebSocket $ws) { $irc_messages = explode("\r\n", rtrim($message->getPayload(), "\r\n")); foreach ($irc_messages as $irc_message) { - $this->logger->debug('received IRC message '.$irc_message); + $this->logger->info('received IRC message '.$irc_message); $this->handleIRCMessage(IRCMessage::fromString($irc_message)); } } public function handleWsClose(int $op, string $reason) { + $this->ready = false; $this->logger->info('websocket connection closed: '.$reason.' ['.$op.']'); if (!$this->shutting_down) { $this->logger->info('reconnecting in 10 seconds'); @@ -111,6 +114,7 @@ class TwitchBot { if ($msg->command == '001') { // successful login $this->joinChannels(); + $this->ready = true; return; } } @@ -145,7 +149,7 @@ class TwitchBot { public function joinChannels() { $this->logger->info('joining channels'); - $channels = Channel::where('twitch_chat', '!=', '')->get(); + $channels = Channel::where('twitch_chat', '!=', '')->where('join', '=', true)->get(); $names = []; foreach ($channels as $channel) { $names[] = $channel->twitch_chat; @@ -156,9 +160,23 @@ class TwitchBot { } } + private function listenCommands() { + $this->getLoop()->addPeriodicTimer(1, function () { + if (!$this->ready) return; + $command = TwitchBotCommand::where('status', '=', 'pending')->oldest()->first(); + if ($command) { + try { + $command->execute($this); + } catch (\Exception $e) { + } + } + }); + + } + public function sendIRCMessage(IRCMessage $msg) { $irc_message = $msg->encode(); - $this->logger->debug('sending IRC message '.$irc_message); + $this->logger->info('sending IRC message '.$irc_message); $this->ws->send($irc_message); } @@ -169,6 +187,7 @@ class TwitchBot { private $connector; private $ws; + private $ready = false; private $shutting_down = false; } diff --git a/app/TwitchBotCommands/BaseCommand.php b/app/TwitchBotCommands/BaseCommand.php new file mode 100644 index 0000000..881d4ed --- /dev/null +++ b/app/TwitchBotCommands/BaseCommand.php @@ -0,0 +1,50 @@ +command) { + case 'join': + return new JoinCommand($bot, $cmd); + case 'part': + return new PartCommand($bot, $cmd); + default: + throw new Exception('unrecognized command'); + } + } + + public abstract function execute(); + + protected function __construct(TwitchBot $bot, TwitchBotCommand $cmd) { + $this->bot = $bot; + $this->command = $cmd; + if ($cmd->tournament && $cmd->tournament->locale) { + App::setLocale($cmd->tournament->locale); + } + } + + protected function getParameter($name) { + return $this->command->parameters[$name]; + } + + protected function getUser() { + if (!$this->hasParameter('user')) { + throw new \Exception('no user in parameters'); + } + return User::findOrFail($this->getParameter('user')); + } + + protected function hasParameter($name) { + return array_key_exists($name, $this->command->parameters); + } + + protected $bot; + protected $command; + +} diff --git a/app/TwitchBotCommands/JoinCommand.php b/app/TwitchBotCommands/JoinCommand.php new file mode 100644 index 0000000..38b6e1e --- /dev/null +++ b/app/TwitchBotCommands/JoinCommand.php @@ -0,0 +1,23 @@ +bot->sendIRCMessage(IRCMessage::join([$this->getParameter('channel')])); + $resolve(); + }); + } + +} diff --git a/app/TwitchBotCommands/PartCommand.php b/app/TwitchBotCommands/PartCommand.php new file mode 100644 index 0000000..0a76a95 --- /dev/null +++ b/app/TwitchBotCommands/PartCommand.php @@ -0,0 +1,23 @@ +bot->sendIRCMessage(IRCMessage::part([$this->getParameter('channel')])); + $resolve(); + }); + } + +} diff --git a/database/migrations/2023_10_04_145919_add_join_field_to_channels_table.php b/database/migrations/2023_10_04_145919_add_join_field_to_channels_table.php new file mode 100644 index 0000000..d9d5651 --- /dev/null +++ b/database/migrations/2023_10_04_145919_add_join_field_to_channels_table.php @@ -0,0 +1,32 @@ +boolean('join')->default(false); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('channels', function (Blueprint $table) { + $table->dropColumn('join'); + }); + } +}; diff --git a/database/migrations/2023_10_06_120722_create_twitch_bot_commands_table.php b/database/migrations/2023_10_06_120722_create_twitch_bot_commands_table.php new file mode 100644 index 0000000..900468e --- /dev/null +++ b/database/migrations/2023_10_06_120722_create_twitch_bot_commands_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tournament_id')->nullable()->constrained(); + $table->foreignId('user_id')->nullable()->constrained(); + $table->string('command'); + $table->text('parameters')->nullable()->default(null); + $table->string('status')->default('hold'); + $table->text('result')->nullable()->default(null); + $table->timestamp('executed_at')->nullable()->default(null); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('twitch_bot_commands'); + } +}; diff --git a/resources/js/components/common/ChannelSelect.js b/resources/js/components/common/ChannelSelect.js new file mode 100644 index 0000000..701f196 --- /dev/null +++ b/resources/js/components/common/ChannelSelect.js @@ -0,0 +1,129 @@ +import axios from 'axios'; +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, Button, Form, ListGroup } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import Icon from './Icon'; +import debounce from '../../helpers/debounce'; + +const ChannelSelect = ({ joinable, manageable, onChange, value }) => { + const [resolved, setResolved] = useState(null); + const [results, setResults] = useState([]); + const [search, setSearch] = useState(''); + const [showResults, setShowResults] = useState(false); + + const ref = useRef(null); + const { t } = useTranslation(); + + useEffect(() => { + const handleEventOutside = e => { + if (ref.current && !ref.current.contains(e.target)) { + setShowResults(false); + } + }; + document.addEventListener('click', handleEventOutside, true); + document.addEventListener('focus', handleEventOutside, true); + return () => { + document.removeEventListener('click', handleEventOutside, true); + document.removeEventListener('focus', handleEventOutside, true); + }; + }, []); + + let ctrl = null; + const fetch = useCallback(debounce(async phrase => { + if (ctrl) { + ctrl.abort(); + } + ctrl = new AbortController(); + try { + const response = await axios.get(`/api/channels`, { + params: { + joinable: joinable ? 1 : 0, + manageable: manageable ? 1 : 0, + phrase, + }, + signal: ctrl.signal, + }); + ctrl = null; + setResults(response.data); + } catch (e) { + ctrl = null; + console.error(e); + } + }, 300), [manageable]); + + useEffect(() => { + fetch(search); + }, [search]); + + useEffect(() => { + if (value) { + axios + .get(`/api/channels/${value}`) + .then(response => { + setResolved(response.data); + }); + } else { + setResolved(null); + } + }, [value]); + + if (value) { + return
+ {resolved ? resolved.title : value} + +
; + } + return
+ setSearch(e.target.value)} + onFocus={() => setShowResults(true)} + type="search" + value={search} + /> +
+ {results.length ? + + {results.map(result => + onChange({ + channel: result, + target: { value: result.id }, + })} + > + {result.title} + + )} + + : + + {t('search.noResults')} + + } +
+
; +}; + +ChannelSelect.propTypes = { + joinable: PropTypes.bool, + manageable: PropTypes.bool, + onChange: PropTypes.func, + value: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string, + ]), +}; + +export default ChannelSelect; diff --git a/resources/js/components/twitch-bot/Controls.js b/resources/js/components/twitch-bot/Controls.js index 158fb7c..0aea916 100644 --- a/resources/js/components/twitch-bot/Controls.js +++ b/resources/js/components/twitch-bot/Controls.js @@ -1,7 +1,76 @@ +import axios from 'axios'; import React from 'react'; +import { Alert, Col, Form, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import toastr from 'toastr'; + +import ChannelSelect from '../common/ChannelSelect'; +import ToggleSwitch from '../common/ToggleSwitch'; const Controls = () => { - return
; + const [channel, setChannel] = React.useState(null); + + const { t } = useTranslation(); + + const join = React.useCallback(async () => { + try { + const rsp = await axios.post(`/api/channels/${channel.id}/join`); + setChannel(rsp.data); + toastr.success(t('twitchBot.joinSuccess')); + } catch (e) { + toastr.error(t('twitchBot.joinError')); + } + }, [channel, t]); + + const part = React.useCallback(async () => { + try { + const rsp = await axios.post(`/api/channels/${channel.id}/part`); + setChannel(rsp.data); + toastr.success(t('twitchBot.partSuccess')); + } catch (e) { + toastr.error(t('twitchBot.partError')); + } + }, [channel, t]); + + return <> + + + {t('twitchBot.channel')} + { setChannel(channel); }} + value={channel ? channel.id : ''} + /> + + {channel ? + + {t('twitchBot.join')} +
+ { + if (value) { + join(); + } else { + part(); + } + }} + value={channel.join} + /> +
+
+ : null} +
+ {channel ? +
+ : + + {t('twitchBot.selectChannel')} + + } + ; }; export default Controls; diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 706e676..23aba69 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -17,6 +17,9 @@ export const isChannelAdmin = (user, channel) => user && channel && user.channel_crews && user.channel_crews.find(c => c.role === 'admin' && c.channel_id === channel.id); +export const isAnyChannelAdmin = user => + user && user.channel_crews && user.channel_crews.find(c => c.role === 'admin'); + // Content export const mayEditContent = user => @@ -37,8 +40,7 @@ export const isTracker = (user, episode) => { export const episodeHasChannel = (episode, channel) => episode && channel && episode.channels && episode.channels.find(c => c.id === channel.id); -export const mayRestreamEpisodes = user => - user && user.channel_crews && user.channel_crews.find(c => c.role === 'admin'); +export const mayRestreamEpisodes = user => isAnyChannelAdmin(user); export const mayEditRestream = (user, episode, channel) => episodeHasChannel(episode, channel) && isChannelAdmin(user, channel); @@ -157,7 +159,7 @@ export const maySeeResults = (user, tournament, round) => // Twitch -export const mayManageTwitchBot = user => isAdmin(user) || hasGlobalRole(user, 'twitch'); +export const mayManageTwitchBot = user => isAnyChannelAdmin(user); // Users diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 9b6891b..8d4e5e4 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -475,9 +475,16 @@ export default { unlockSuccess: 'Turnier entsperrt', }, twitchBot: { + channel: 'Channel', controls: 'Controls', heading: 'Twitch Bot', + join: 'Join', + joinError: 'Fehler beim Betreten', + joinSuccess: 'Betreten', noManagePermission: 'Du verfügst nicht über die notwendigen Berechtigungen, um den Twitch Bot zu administrieren.', + partError: 'Fehler beim Verlassen', + partSuccess: 'Verlassen', + selectChannel: 'Bitte wählen einen Channel, den du verändern möchtest.', }, users: { discordTag: 'Discord Tag', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 58d406c..480a57c 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -475,9 +475,16 @@ export default { unlockSuccess: 'Tournament unlocked', }, twitchBot: { + channel: 'Channel', controls: 'Controls', heading: 'Twitch Bot', + join: 'Join', + joinError: 'Error joining channel', + joinSuccess: 'Joined', noManagePermission: 'You lack the required privileges to manage the twitch bot.', + partError: 'Error parting channel', + partSuccess: 'Parted', + selectChannel: 'Please select a channel to manage.', }, users: { discordTag: 'Discord tag', diff --git a/resources/sass/episodes.scss b/resources/sass/episodes.scss index eed1913..6caf0dd 100644 --- a/resources/sass/episodes.scss +++ b/resources/sass/episodes.scss @@ -89,3 +89,26 @@ } } } + +.channel-select { + .search-results-holder { + position: relative; + } + .search-results { + position: absolute; + left: 0; + top: 100%; + z-index: 1; + width: 100%; + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: 1ex 1ex 1ex rgba(0, 0, 0, 0.5); + } + &.collapsed .search-results { + display: none; + } + &.expanded .search-input { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} diff --git a/routes/api.php b/routes/api.php index 1d1c580..6380b2d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -24,6 +24,11 @@ Route::post('alttp-seed/{hash}/retry', 'App\Http\Controllers\AlttpSeedController Route::post('application/{application}/accept', 'App\Http\Controllers\ApplicationController@accept'); Route::post('application/{application}/reject', 'App\Http\Controllers\ApplicationController@reject'); +Route::get('channels', 'App\Http\Controllers\ChannelController@search'); +Route::get('channels/{channel}', 'App\Http\Controllers\ChannelController@single'); +Route::post('channels/{channel}/join', 'App\Http\Controllers\ChannelController@join'); +Route::post('channels/{channel}/part', 'App\Http\Controllers\ChannelController@part'); + Route::get('content', 'App\Http\Controllers\TechniqueController@search'); Route::get('content/{tech:name}', 'App\Http\Controllers\TechniqueController@single'); Route::put('content/{content}', 'App\Http\Controllers\TechniqueController@update'); -- 2.39.2