From 85879ea0c27ce6506919e2c083a139c470c0952c Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 19 Jan 2024 17:35:28 +0100 Subject: [PATCH] add chat bot --- app/Console/Commands/TwitchBotCommand.php | 4 +- app/Console/Commands/TwitchChatBotCommand.php | 43 +++++++ app/Models/Channel.php | 1 + app/Models/ChatLog.php | 2 +- app/TwitchBot/TwitchAppBot.php | 69 +++++++++++ app/TwitchBot/TwitchBot.php | 68 ++++------- app/TwitchBot/TwitchChatBot.php | 107 ++++++++++++++++++ .../2024_01_19_140728_channel_chat_flag.php | 32 ++++++ 8 files changed, 275 insertions(+), 51 deletions(-) create mode 100644 app/Console/Commands/TwitchChatBotCommand.php create mode 100644 app/TwitchBot/TwitchAppBot.php create mode 100644 app/TwitchBot/TwitchChatBot.php create mode 100644 database/migrations/2024_01_19_140728_channel_chat_flag.php diff --git a/app/Console/Commands/TwitchBotCommand.php b/app/Console/Commands/TwitchBotCommand.php index 55ae34b..241a885 100644 --- a/app/Console/Commands/TwitchBotCommand.php +++ b/app/Console/Commands/TwitchBotCommand.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\TwitchBot\TwitchBot; +use App\TwitchBot\TwitchAppBot; use Illuminate\Console\Command; class TwitchBotCommand extends Command { @@ -27,7 +27,7 @@ class TwitchBotCommand extends Command { * @return int */ public function handle() { - $bot = new TwitchBot(); + $bot = new TwitchAppBot(); $bot->getLoop()->addSignal(SIGINT, function() use ($bot) { $bot->stop(); diff --git a/app/Console/Commands/TwitchChatBotCommand.php b/app/Console/Commands/TwitchChatBotCommand.php new file mode 100644 index 0000000..753a393 --- /dev/null +++ b/app/Console/Commands/TwitchChatBotCommand.php @@ -0,0 +1,43 @@ +getLoop()->addSignal(SIGINT, function() use ($bot) { + $bot->stop(); + }); + + $bot->run(); + + return 0; + } + +} + +?> diff --git a/app/Models/Channel.php b/app/Models/Channel.php index 025bb34..255b52c 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -31,6 +31,7 @@ class Channel extends Model } protected $casts = [ + 'chat' => 'boolean', 'chat_commands' => 'array', 'languages' => 'array', 'join' => 'boolean', diff --git a/app/Models/ChatLog.php b/app/Models/ChatLog.php index aa4e7ae..6f72c35 100644 --- a/app/Models/ChatLog.php +++ b/app/Models/ChatLog.php @@ -25,7 +25,7 @@ class ChatLog extends Model { $this->type = 'system'; return; } - if ($this->nick == 'localhorsttv') { + if (in_array($this->nick, ['horstiebot', 'localhorsttv'])) { $this->type = 'self'; return; } diff --git a/app/TwitchBot/TwitchAppBot.php b/app/TwitchBot/TwitchAppBot.php new file mode 100644 index 0000000..4e2c7d4 --- /dev/null +++ b/app/TwitchBot/TwitchAppBot.php @@ -0,0 +1,69 @@ +listenCommands(); + } + + public function logMessage(IRCMessage $msg) { + $msg->log(); + } + + public function handlePrivMsg(IRCMessage $msg) { + $target = $msg->getPrivMsgTarget(); + if ($target[0] != '#') return; // direct message + $text = $msg->getText(); + if ($text[0] != '!') return; + $channel = $this->getMessageChannel($msg); + if (!$channel) return; + $this->handleChatCommand($channel, $msg); + } + + public function handleChatCommand(Channel $channel, IRCMessage $msg) { + $cmd = explode(' ', ltrim($msg->getText(), '!'), 2); + if (!isset($channel->chat_commands[$cmd[0]])) return; + $config = $channel->chat_commands[$cmd[0]]; + $this->getLogger()->info('got command '.$cmd[0].' on channel '.$channel->title); + try { + $command = ChatCommand::create($this, $channel, $config); + $command->execute($cmd[1] ?? ''); + } catch (\Exception $e) { + $this->getLogger()->warning('error executing command '.$cmd[0].' on channel '.$channel->title.': '.$e->getMessage()); + } + } + + public function joinChannels() { + $this->getLogger()->info('joining channels'); + $channels = Channel::where('twitch_chat', '!=', '')->where('join', '=', true)->get(); + $names = []; + foreach ($channels as $channel) { + $names[] = $channel->twitch_chat; + } + $chunks = array_chunk($names, 10); + foreach ($chunks as $chunk) { + $this->sendIRCMessage(IRCMessage::join($chunk)); + } + } + + + private function listenCommands() { + $this->getLoop()->addPeriodicTimer(1, function () { + if (!$this->isReady()) return; + $command = TwitchBotCommand::where('status', '=', 'pending')->oldest()->first(); + if ($command) { + try { + $command->execute($this); + } catch (\Exception $e) { + } + } + }); + } + +} diff --git a/app/TwitchBot/TwitchBot.php b/app/TwitchBot/TwitchBot.php index c8d77a3..79b6bf1 100644 --- a/app/TwitchBot/TwitchBot.php +++ b/app/TwitchBot/TwitchBot.php @@ -3,7 +3,6 @@ namespace App\TwitchBot; use App\Models\Channel; -use App\Models\TwitchBotCommand; use App\Models\TwitchToken; use Monolog\Handler\StreamHandler; use Monolog\Logger; @@ -14,18 +13,18 @@ use React\EventLoop\Loop; class TwitchBot { - public function __construct() { + public function __construct($nick) { + $this->nick = $nick; $this->logger = new Logger('TwitchBot'); $this->logger->pushHandler(new StreamHandler('php://stdout', Logger::INFO)); - $this->token = TwitchToken::firstWhere('nick', 'localhorsttv'); + $this->token = TwitchToken::firstWhere('nick', $nick); if (!$this->token) { throw new \Exception('unable to find access token'); } $this->connector = new Connector(); $this->connect(); - $this->listenCommands(); $this->startPinger(); } @@ -37,6 +36,10 @@ class TwitchBot { return Loop::get(); } + public function isReady() { + return $this->ready; + } + public function run() { $this->shutting_down = false; $this->getLoop()->run(); @@ -106,7 +109,7 @@ class TwitchBot { if ($msg->isPong()) { return; } - $msg->log(); + $this->logMessage($msg); if ($msg->isPrivMsg()) { $this->handlePrivMsg($msg); return; @@ -125,58 +128,26 @@ class TwitchBot { } } - public function handlePrivMsg(IRCMessage $msg) { + public function getMessageChannel(IRCMessage $msg) { $target = $msg->getPrivMsgTarget(); - if ($target[0] != '#') return; // direct message - $text = $msg->getText(); - if ($text[0] != '!') return; - $channel = Channel::firstWhere('twitch_chat', '=', $target); - if (!$channel) return; - $this->handleChatCommand($channel, $msg); - } - - public function handleChatCommand(Channel $channel, IRCMessage $msg) { - $cmd = explode(' ', ltrim($msg->getText(), '!'), 2); - if (!isset($channel->chat_commands[$cmd[0]])) return; - $config = $channel->chat_commands[$cmd[0]]; - $this->logger->info('got command '.$cmd[0].' on channel '.$channel->title); - try { - $command = ChatCommand::create($this, $channel, $config); - $command->execute($cmd[1] ?? ''); - } catch (\Exception $e) { - $this->logger->warning('error executing command '.$cmd[0].' on channel '.$channel->title.': '.$e->getMessage()); + if (substr($target, 0, 1) !== '#') { + $target = '#'.$target; } + return Channel::firstWhere('twitch_chat', '=', $target); + } + + public function logMessage(IRCMessage $msg) { + } + + public function handlePrivMsg(IRCMessage $msg) { } public function login() { $this->ws->send('PASS oauth:'.$this->token->access); - $this->ws->send('NICK localhorsttv'); + $this->ws->send('NICK '.$this->nick); } public function joinChannels() { - $this->logger->info('joining channels'); - $channels = Channel::where('twitch_chat', '!=', '')->where('join', '=', true)->get(); - $names = []; - foreach ($channels as $channel) { - $names[] = $channel->twitch_chat; - } - $chunks = array_chunk($names, 10); - foreach ($chunks as $chunk) { - $this->sendIRCMessage(IRCMessage::join($chunk)); - } - } - - 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) { - } - } - }); } private function startPinger() { @@ -200,6 +171,7 @@ class TwitchBot { private $logger; + private $nick; private $token; private $connector; diff --git a/app/TwitchBot/TwitchChatBot.php b/app/TwitchBot/TwitchChatBot.php new file mode 100644 index 0000000..0469fbc --- /dev/null +++ b/app/TwitchBot/TwitchChatBot.php @@ -0,0 +1,107 @@ +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->startTimer(); + } + + public function joinChannels() { + $this->getLogger()->info('joining channels'); + $names = []; + foreach ($this->channels as $channel) { + $names[] = $channel->twitch_chat; + } + $chunks = array_chunk($names, 10); + foreach ($chunks as $chunk) { + $this->sendIRCMessage(IRCMessage::join($chunk)); + } + } + + public function logMessage(IRCMessage $msg) { + $channel = $this->getMessageChannel($msg); + if ($channel && !$channel->join) { + $msg->log(); + } + } + + public function handlePrivMsg(IRCMessage $msg) { + if ($msg->nick == 'horstiebot') return; + $channel = $this->getMessageChannel($msg); + if (!$channel) return; + $this->tagChannelRead($channel); + } + + + private function startTimer() { + $this->getLoop()->addPeriodicTimer(1, function () { + if (!$this->isReady()) return; + foreach ($this->channels as $channel) { + $this->decideSend($channel); + } + }); + } + + private function decideSend(Channel $channel) { + $notes = $this->notes[$channel->id]; + if ($notes['read_since_last_write'] < $notes['wait_msgs']) { + return; + } + if (time() - $notes['last_write'] < $notes['wait_time']) { + return; + } + if ($notes['read_since_last_write'] == $notes['wait_msgs'] && time() - $notes['last_read'] < 3) { + // don't immediately respond if we crossed the msg threshold last + return; + } + $text = $this->randomMsg($channel); + if (!$text) return; + $this->tagChannelWrite($channel); + $this->sendIRCMessage(IRCMessage::privmsg($channel->twitch_chat, $text)); + } + + private function randomMsg(Channel $channel) { + $line = ChatLog::where('type', '=', 'chat')->where('banned', '=', false)->inRandomOrder()->first(); + return $line->text_content; + } + + private function randomWaitMsgs(Channel $channel) { + return random_int(1, 10); + } + + private function randomWaitTime(Channel $channel) { + return random_int(1, 1800); + } + + private function tagChannelRead(Channel $channel) { + $this->notes[$channel->id]['last_read'] = time(); + ++$this->notes[$channel->id]['read_since_last_write']; + } + + private function tagChannelWrite(Channel $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); + $this->notes[$channel->id]['wait_time'] = $this->randomWaitTime($channel); + } + + private $channels; + private $notes = []; + +} diff --git a/database/migrations/2024_01_19_140728_channel_chat_flag.php b/database/migrations/2024_01_19_140728_channel_chat_flag.php new file mode 100644 index 0000000..8af1700 --- /dev/null +++ b/database/migrations/2024_01_19_140728_channel_chat_flag.php @@ -0,0 +1,32 @@ +boolean('chat')->default(false); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('channels', function (Blueprint $table) { + $table->dropColumn('chat'); + }); + } +}; -- 2.39.2