--- /dev/null
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Channel;
+use App\Models\TwitchBotCommand;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\Request;
+
+class ChannelController extends Controller {
+
+ public function search(Request $request) {
+ $validatedData = $request->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();
+ }
+
+}
->first();
}
+ public function crews() {
+ return $this->hasMany(ChannelCrew::class);
+ }
+
public function episodes() {
return $this->belongsToMany(Episode::class)
->using(Restream::class)
protected $casts = [
'chat_commands' => 'array',
'languages' => 'array',
+ 'join' => 'boolean',
];
protected $hidden = [
--- /dev/null
+<?php
+
+namespace App\Models;
+
+use App\TwitchBot\TwitchBot;
+use App\TwitchBotCommands\BaseCommand;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class TwitchBotCommand extends Model
+{
+ use HasFactory;
+
+ public static function join($channel) {
+ $cmd = new TwitchBotCommand();
+ $cmd->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',
+ ];
+
+}
*/
public function view(User $user, Channel $channel)
{
- return $channel->event->visible;
+ return true;
}
/**
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';
namespace App\TwitchBot;
use App\Models\Channel;
+use App\Models\TwitchBotCommand;
use App\Models\TwitchToken;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
$this->connector = new Connector();
$this->connect();
+ $this->listenCommands();
}
public function getLogger() {
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');
if ($msg->command == '001') {
// successful login
$this->joinChannels();
+ $this->ready = true;
return;
}
}
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;
}
}
+ 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);
}
private $connector;
private $ws;
+ private $ready = false;
private $shutting_down = false;
}
--- /dev/null
+<?php
+
+namespace App\TwitchBotCommands;
+
+use App\Models\TwitchBotCommand;
+use App\Models\User;
+use App\TwitchBot\TwitchBot;
+
+abstract class BaseCommand {
+
+ public static function resolve(TwitchBot $bot, TwitchBotCommand $cmd) {
+ switch ($cmd->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;
+
+}
--- /dev/null
+<?php
+
+namespace App\TwitchBotCommands;
+
+use App\Models\TwitchBotCommand;
+use App\TwitchBot\IRCMessage;
+use App\TwitchBot\TwitchBot;
+use React\Promise\Promise;
+
+class JoinCommand extends BaseCommand {
+
+ public function __construct(TwitchBot $bot, TwitchBotCommand $cmd) {
+ parent::__construct($bot, $cmd);
+ }
+
+ public function execute() {
+ return new Promise(function($resolve) {
+ $this->bot->sendIRCMessage(IRCMessage::join([$this->getParameter('channel')]));
+ $resolve();
+ });
+ }
+
+}
--- /dev/null
+<?php
+
+namespace App\TwitchBotCommands;
+
+use App\Models\TwitchBotCommand;
+use App\TwitchBot\IRCMessage;
+use App\TwitchBot\TwitchBot;
+use React\Promise\Promise;
+
+class PartCommand extends BaseCommand {
+
+ public function __construct(TwitchBot $bot, TwitchBotCommand $cmd) {
+ parent::__construct($bot, $cmd);
+ }
+
+ public function execute() {
+ return new Promise(function($resolve) {
+ $this->bot->sendIRCMessage(IRCMessage::part([$this->getParameter('channel')]));
+ $resolve();
+ });
+ }
+
+}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('channels', function (Blueprint $table) {
+ $table->boolean('join')->default(false);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('channels', function (Blueprint $table) {
+ $table->dropColumn('join');
+ });
+ }
+};
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('twitch_bot_commands', function (Blueprint $table) {
+ $table->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');
+ }
+};
--- /dev/null
+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 <div className="d-flex align-items-center justify-content-between">
+ <span>{resolved ? resolved.title : value}</span>
+ <Button
+ className="ms-2"
+ onClick={() => onChange({ channel: null, target: { value: '' }})}
+ title={t('button.unset')}
+ variant="outline-danger"
+ >
+ <Icon.REMOVE title="" />
+ </Button>
+ </div>;
+ }
+ return <div className={`channel-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+ <Form.Control
+ className="search-input"
+ name={Math.random().toString(20).substr(2, 10)}
+ onChange={e => setSearch(e.target.value)}
+ onFocus={() => setShowResults(true)}
+ type="search"
+ value={search}
+ />
+ <div className="search-results-holder">
+ {results.length ?
+ <ListGroup className="search-results">
+ {results.map(result =>
+ <ListGroup.Item
+ action
+ key={result.id}
+ onClick={() => onChange({
+ channel: result,
+ target: { value: result.id },
+ })}
+ >
+ {result.title}
+ </ListGroup.Item>
+ )}
+ </ListGroup>
+ :
+ <Alert className="search-results" variant="info">
+ {t('search.noResults')}
+ </Alert>
+ }
+ </div>
+ </div>;
+};
+
+ChannelSelect.propTypes = {
+ joinable: PropTypes.bool,
+ manageable: PropTypes.bool,
+ onChange: PropTypes.func,
+ value: PropTypes.oneOfType([
+ PropTypes.number,
+ PropTypes.string,
+ ]),
+};
+
+export default ChannelSelect;
+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 <div />;
+ 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 <>
+ <Row className="mb-4">
+ <Form.Group as={Col} md={6}>
+ <Form.Label>{t('twitchBot.channel')}</Form.Label>
+ <Form.Control
+ as={ChannelSelect}
+ joinable
+ manageable
+ onChange={({ channel }) => { setChannel(channel); }}
+ value={channel ? channel.id : ''}
+ />
+ </Form.Group>
+ {channel ?
+ <Form.Group as={Col} md={6}>
+ <Form.Label>{t('twitchBot.join')}</Form.Label>
+ <div>
+ <Form.Control
+ as={ToggleSwitch}
+ onChange={({ target: { value } }) => {
+ if (value) {
+ join();
+ } else {
+ part();
+ }
+ }}
+ value={channel.join}
+ />
+ </div>
+ </Form.Group>
+ : null}
+ </Row>
+ {channel ?
+ <div />
+ :
+ <Alert variant="info">
+ {t('twitchBot.selectChannel')}
+ </Alert>
+ }
+ </>;
};
export default Controls;
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 =>
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);
// Twitch
-export const mayManageTwitchBot = user => isAdmin(user) || hasGlobalRole(user, 'twitch');
+export const mayManageTwitchBot = user => isAnyChannelAdmin(user);
// Users
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',
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',
}
}
}
+
+.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;
+ }
+}
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');