From e49b130505f5712075dca2ff990e5a63fc90ce3c Mon Sep 17 00:00:00 2001
From: Daniel Karbach <daniel.karbach@localhorst.tv>
Date: Sun, 21 Jan 2024 16:39:12 +0100
Subject: [PATCH] chat bot settings

---
 app/Http/Controllers/ChannelController.php    |  16 ++
 app/Models/Channel.php                        |   1 +
 app/TwitchBot/TwitchChatBot.php               |  50 ++++--
 ...024_01_21_134505_channel_chat_settings.php |  32 ++++
 .../components/twitch-bot/ChatSettingsForm.js | 148 ++++++++++++++++++
 .../js/components/twitch-bot/Controls.js      |  15 ++
 resources/js/i18n/de.js                       |   9 +-
 resources/js/i18n/en.js                       |   6 +
 routes/api.php                                |   1 +
 9 files changed, 263 insertions(+), 15 deletions(-)
 create mode 100644 database/migrations/2024_01_21_134505_channel_chat_settings.php
 create mode 100644 resources/js/components/twitch-bot/ChatSettingsForm.js

diff --git a/app/Http/Controllers/ChannelController.php b/app/Http/Controllers/ChannelController.php
index b735a6f..f1f3cdb 100644
--- a/app/Http/Controllers/ChannelController.php
+++ b/app/Http/Controllers/ChannelController.php
@@ -56,6 +56,22 @@ class ChannelController extends Controller {
 		return $channel->toJson();
 	}
 
+	public function chatSettings(Request $request, Channel $channel) {
+		if (!$channel->twitch_chat) {
+			throw new \Exception('channel has no twitch chat set');
+		}
+		$validatedData = $request->validate([
+			'wait_msgs_min' => 'integer|min:1',
+			'wait_msgs_max' => 'integer',
+			'wait_time_min' => 'integer',
+			'wait_time_max' => 'integer',
+		]);
+		$this->authorize('editRestream', $channel);
+		$channel->chat_settings = $validatedData;
+		$channel->save();
+		return $channel->toJson();
+	}
+
 	public function join(Request $request, Channel $channel) {
 		if (!$channel->twitch_chat) {
 			throw new \Exception('channel has no twitch chat set');
diff --git a/app/Models/Channel.php b/app/Models/Channel.php
index 255b52c..d1f0fdc 100644
--- a/app/Models/Channel.php
+++ b/app/Models/Channel.php
@@ -33,6 +33,7 @@ class Channel extends Model
 	protected $casts = [
 		'chat' => 'boolean',
 		'chat_commands' => 'array',
+		'chat_settings' => 'array',
 		'languages' => 'array',
 		'join' => 'boolean',
 	];
diff --git a/app/TwitchBot/TwitchChatBot.php b/app/TwitchBot/TwitchChatBot.php
index 7a6eb1c..0896c2b 100644
--- a/app/TwitchBot/TwitchChatBot.php
+++ b/app/TwitchBot/TwitchChatBot.php
@@ -9,16 +9,7 @@ class TwitchChatBot extends TwitchBot {
 
 	public function __construct() {
 		parent::__construct('horstiebot');
-		$this->channels = Channel::where('twitch_chat', '!=', '')->where('chat', '=', true)->get();
-		foreach ($this->channels as $channel) {
-			$this->notes[$channel->id] = [
-				'last_read' => 0,
-				'last_write' => time(),
-				'read_since_last_write' => 0,
-				'wait_msgs' => $this->randomWaitMsgs($channel),
-				'wait_time' => $this->randomWaitTime($channel),
-			];
-		}
+		$this->updateChannels();
 		$this->startTimer();
 		$this->listenCommands();
 	}
@@ -57,10 +48,17 @@ class TwitchChatBot extends TwitchBot {
 				$this->decideSend($channel);
 			}
 		});
+		$this->getLoop()->addPeriodicTimer(60, function () {
+			$this->updateChannels();
+		});
+	}
+
+	private function updateChannels() {
+		$this->channels = Channel::where('twitch_chat', '!=', '')->where('chat', '=', true)->get();
 	}
 
 	private function decideSend(Channel $channel) {
-		$notes = $this->notes[$channel->id];
+		$notes = $this->getNotes($channel);
 		if ($notes['read_since_last_write'] < $notes['wait_msgs']) {
 			return;
 		}
@@ -77,6 +75,26 @@ class TwitchChatBot extends TwitchBot {
 		$this->sendIRCMessage(IRCMessage::privmsg($channel->twitch_chat, $text));
 	}
 
+	private function getChatSetting(Channel $channel, $name, $default = null) {
+		if (array_key_exists($name, $channel->chat_settings)) {
+			return $channel->chat_settings[$name];
+		}
+		return $default;
+	}
+
+	private function getNotes(Channel $channel) {
+		if (!isset($this->notes[$channel->id])) {
+			$this->notes[$channel->id] = [
+				'last_read' => 0,
+				'last_write' => time(),
+				'read_since_last_write' => 0,
+				'wait_msgs' => $this->randomWaitMsgs($channel),
+				'wait_time' => $this->randomWaitTime($channel),
+			];
+		}
+		return $this->notes[$channel->id];
+	}
+
 	private function randomMsg(Channel $channel) {
 		$line = ChatLog::where('type', '=', 'chat')
 			->where('banned', '=', false)
@@ -91,19 +109,25 @@ class TwitchChatBot extends TwitchBot {
 	}
 
 	private function randomWaitMsgs(Channel $channel) {
-		return random_int(1, 10);
+		$min = $this->getChatSetting($channel, 'wait_msgs_min', 1);
+		$max = $this->getChatSetting($channel, 'wait_msgs_max', 10);
+		return random_int($min, $max);
 	}
 
 	private function randomWaitTime(Channel $channel) {
-		return random_int(1, 900);
+		$min = $this->getChatSetting($channel, 'wait_time_min', 1);
+		$max = $this->getChatSetting($channel, 'wait_time_max', 900);
+		return random_int($min, $max);
 	}
 
 	private function tagChannelRead(Channel $channel) {
+		$this->getNotes($channel);
 		$this->notes[$channel->id]['last_read'] = time();
 		++$this->notes[$channel->id]['read_since_last_write'];
 	}
 
 	private function tagChannelWrite(Channel $channel) {
+		$this->getNotes($channel);
 		$this->notes[$channel->id]['last_write'] = time();
 		$this->notes[$channel->id]['read_since_last_write'] = 0;
 		$this->notes[$channel->id]['wait_msgs'] = $this->randomWaitMsgs($channel);
diff --git a/database/migrations/2024_01_21_134505_channel_chat_settings.php b/database/migrations/2024_01_21_134505_channel_chat_settings.php
new file mode 100644
index 0000000..755aecd
--- /dev/null
+++ b/database/migrations/2024_01_21_134505_channel_chat_settings.php
@@ -0,0 +1,32 @@
+<?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->text('chat_settings')->default('{}');
+		});
+	}
+
+	/**
+	 * Reverse the migrations.
+	 *
+	 * @return void
+	 */
+	public function down()
+	{
+		Schema::table('channels', function (Blueprint $table) {
+			$table->dropColumn('chat_settings');
+		});
+	}
+};
diff --git a/resources/js/components/twitch-bot/ChatSettingsForm.js b/resources/js/components/twitch-bot/ChatSettingsForm.js
new file mode 100644
index 0000000..d9c03d7
--- /dev/null
+++ b/resources/js/components/twitch-bot/ChatSettingsForm.js
@@ -0,0 +1,148 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { formatTime, parseTime } from '../../helpers/Result';
+import yup from '../../schema/yup';
+
+const ChatSettingsForm = ({
+	dirty,
+	errors,
+	handleBlur,
+	handleChange,
+	handleSubmit,
+	isSubmitting,
+	touched,
+	values,
+}) => {
+	const { t } = useTranslation();
+
+	return <Form noValidate onSubmit={handleSubmit}>
+		<Row>
+			<Form.Group as={Col} md={6} controlId="chatSettings.wait_msgs_min">
+				<Form.Label>{t('twitchBot.chatWaitMsgsMin')}</Form.Label>
+				<Form.Control
+					isInvalid={!!(touched.wait_msgs_min && errors.wait_msgs_min)}
+					name="wait_msgs_min"
+					min="1"
+					onBlur={handleBlur}
+					onChange={handleChange}
+					type="number"
+					value={values.wait_msgs_min || 1}
+				/>
+			</Form.Group>
+			<Form.Group as={Col} md={6} controlId="chatSettings.wait_msgs_max">
+				<Form.Label>{t('twitchBot.chatWaitMsgsMax')}</Form.Label>
+				<Form.Control
+					isInvalid={!!(touched.wait_msgs_max && errors.wait_msgs_max)}
+					name="wait_msgs_max"
+					min="1"
+					onBlur={handleBlur}
+					onChange={handleChange}
+					type="number"
+					value={values.wait_msgs_max || 10}
+				/>
+			</Form.Group>
+			<Form.Group as={Col} md={6} controlId="chatSettings.wait_time_min">
+				<Form.Label>{t('twitchBot.chatWaitTimeMin')}</Form.Label>
+				<Form.Control
+					isInvalid={!!(touched.wait_time_min && errors.wait_time_min)}
+					name="wait_time_min"
+					onBlur={handleBlur}
+					onChange={handleChange}
+					type="text"
+					value={values.wait_time_min || '0'}
+				/>
+				{touched.wait_time_min && errors.wait_time_min ?
+					<Form.Control.Feedback type="invalid">
+						{t(errors.wait_time_min)}
+					</Form.Control.Feedback>
+				:
+					<Form.Text muted>
+						{formatTime({ time: parseTime(values.wait_time_min)})}
+					</Form.Text>
+				}
+			</Form.Group>
+			<Form.Group as={Col} md={6} controlId="chatSettings.wait_time_max">
+				<Form.Label>{t('twitchBot.chatWaitTimeMax')}</Form.Label>
+				<Form.Control
+					isInvalid={!!(touched.wait_time_max && errors.wait_time_max)}
+					name="wait_time_max"
+					onBlur={handleBlur}
+					onChange={handleChange}
+					type="text"
+					value={values.wait_time_max || '15:00'}
+				/>
+				{touched.wait_time_max && errors.wait_time_max ?
+					<Form.Control.Feedback type="invalid">
+						{t(errors.wait_time_max)}
+					</Form.Control.Feedback>
+				:
+					<Form.Text muted>
+						{formatTime({ time: parseTime(values.wait_time_max)})}
+					</Form.Text>
+				}
+			</Form.Group>
+		</Row>
+		<div className="button-bar mt-3">
+			<Button disabled={!dirty || isSubmitting} type="submit" variant="primary">
+				{t('button.save')}
+			</Button>
+		</div>
+	</Form>;
+};
+
+ChatSettingsForm.propTypes = {
+	dirty: PropTypes.bool,
+	errors: PropTypes.shape({
+		wait_msgs_max: PropTypes.string,
+		wait_msgs_min: PropTypes.string,
+		wait_time_min: PropTypes.string,
+		wait_time_max: PropTypes.string,
+	}),
+	handleBlur: PropTypes.func,
+	handleChange: PropTypes.func,
+	handleSubmit: PropTypes.func,
+	isSubmitting: PropTypes.bool,
+	touched: PropTypes.shape({
+		wait_msgs_max: PropTypes.bool,
+		wait_msgs_min: PropTypes.bool,
+		wait_time_min: PropTypes.bool,
+		wait_time_max: PropTypes.bool,
+	}),
+	values: PropTypes.shape({
+		wait_msgs_max: PropTypes.number,
+		wait_msgs_min: PropTypes.number,
+		wait_time_min: PropTypes.string,
+		wait_time_max: PropTypes.string,
+	}),
+};
+
+export default withFormik({
+	displayName: 'ChatSettingsForm',
+	enableReinitialize: true,
+	handleSubmit: async (values, actions) => {
+		const { onSubmit } = actions.props;
+		await onSubmit({
+			...values,
+			wait_time_min: parseTime(values.wait_time_min) || 0,
+			wait_time_max: parseTime(values.wait_time_max) || 0,
+		});
+	},
+	mapPropsToValues: ({ channel }) => ({
+		wait_msgs_min: channel.chat_settings.wait_msgs_min || 1,
+		wait_msgs_max: channel.chat_settings.wait_msgs_max || 10,
+		wait_time_min: channel.chat_settings.wait_time_min
+			? formatTime({ time: channel.chat_settings.wait_time_min }) : '0',
+		wait_time_max: channel.chat_settings.wait_time_max
+			? formatTime({ time: channel.chat_settings.wait_time_max }) : '15:00',
+	}),
+	validationSchema: yup.object().shape({
+		wait_msgs_min: yup.number().min(1),
+		wait_msgs_max: yup.number().min(1),
+		wait_time_min: yup.string().time(),
+		wait_time_max: yup.string().time(),
+	}),
+})(ChatSettingsForm);
diff --git a/resources/js/components/twitch-bot/Controls.js b/resources/js/components/twitch-bot/Controls.js
index cc062e8..bdc8938 100644
--- a/resources/js/components/twitch-bot/Controls.js
+++ b/resources/js/components/twitch-bot/Controls.js
@@ -4,6 +4,7 @@ import { Alert, Button, Col, Form, Row } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
 import toastr from 'toastr';
 
+import ChatSettingsForm from './ChatSettingsForm';
 import ChannelSelect from '../common/ChannelSelect';
 import ToggleSwitch from '../common/ToggleSwitch';
 
@@ -45,6 +46,16 @@ const Controls = () => {
 		}
 	}, [channel, t]);
 
+	const saveChatSettings = React.useCallback(async (values) => {
+		try {
+			const rsp = await axios.post(`/api/channels/${channel.id}/chat-settings`, values);
+			setChannel(rsp.data);
+			toastr.success(t('twitchBot.saveSuccess'));
+		} catch (e) {
+			toastr.error(t('twitchBot.saveError'));
+		}
+	}, [channel, t]);
+
 	return <>
 		<Row className="mb-4">
 			<Form.Group as={Col} md={6}>
@@ -126,6 +137,10 @@ const Controls = () => {
 						</Button>
 					</div>
 				</Form.Group>
+				<Col md={6}>
+					<h3>{t('twitchBot.chatSettings')}</h3>
+					<ChatSettingsForm channel={channel} onSubmit={saveChatSettings} />
+				</Col>
 			</Row>
 		:
 			<Alert variant="info">
diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js
index bb5c08c..907ab3d 100644
--- a/resources/js/i18n/de.js
+++ b/resources/js/i18n/de.js
@@ -487,10 +487,13 @@ export default {
 		},
 		twitchBot: {
 			channel: 'Channel',
-			chatApp: 'Chat als App Bot',
-			chatChat: 'Chat als Chat Bot',
+			chat: 'Chat',
 			chatError: 'Fehler beim Senden',
 			chatSuccess: 'Nachricht in Warteschlange',
+			chatWaitMsgsMin: 'Min. Messages',
+			chatWaitMsgsMax: 'Max. Messages',
+			chatWaitTimeMin: 'Min. Zeit',
+			chatWaitTimeMax: 'Max. Zeit',
 			controls: 'Controls',
 			heading: 'Twitch Bot',
 			joinApp: 'Join als App Bot',
@@ -500,6 +503,8 @@ export default {
 			noManagePermission: 'Du verfügst nicht über die notwendigen Berechtigungen, um den Twitch Bot zu administrieren.',
 			partError: 'Fehler beim Verlassen',
 			partSuccess: 'Verlassen',
+			saveError: 'Fehler beim Speichern',
+			saveSuccess: 'Gespeichert',
 			selectChannel: 'Bitte wählen einen Channel, den du verändern möchtest.',
 			sendApp: 'Als App Bot senden',
 			sendChat: 'Als Chat Bot senden',
diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js
index 669c56e..401a7e4 100644
--- a/resources/js/i18n/en.js
+++ b/resources/js/i18n/en.js
@@ -490,6 +490,10 @@ export default {
 			chat: 'Chat',
 			chatError: 'Error sending message',
 			chatSuccess: 'Message queued',
+			chatWaitMsgsMin: 'Min. messages',
+			chatWaitMsgsMax: 'Max. messages',
+			chatWaitTimeMin: 'Min. time',
+			chatWaitTimeMax: 'Max. time',
 			controls: 'Controls',
 			heading: 'Twitch Bot',
 			joinApp: 'Join as App Bot',
@@ -499,6 +503,8 @@ export default {
 			noManagePermission: 'You lack the required privileges to manage the twitch bot.',
 			partError: 'Error parting channel',
 			partSuccess: 'Parted',
+			saveError: 'Error saving',
+			saveSuccess: 'Saved',
 			selectChannel: 'Please select a channel to manage.',
 			sendApp: 'Send as App Bot',
 			sendChat: 'Send as Chat Bot',
diff --git a/routes/api.php b/routes/api.php
index 4333494..2b8b682 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -27,6 +27,7 @@ Route::post('application/{application}/reject', 'App\Http\Controllers\Applicatio
 Route::get('channels', 'App\Http\Controllers\ChannelController@search');
 Route::get('channels/{channel}', 'App\Http\Controllers\ChannelController@single');
 Route::post('channels/{channel}/chat', 'App\Http\Controllers\ChannelController@chat');
+Route::post('channels/{channel}/chat-settings', 'App\Http\Controllers\ChannelController@chatSettings');
 Route::post('channels/{channel}/join', 'App\Http\Controllers\ChannelController@join');
 Route::post('channels/{channel}/part', 'App\Http\Controllers\ChannelController@part');
 
-- 
2.39.5