]> git.localhorst.tv Git - alttp.git/commitdiff
add discord bot chat command
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 2 Jul 2025 12:03:17 +0000 (14:03 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 2 Jul 2025 12:03:17 +0000 (14:03 +0200)
15 files changed:
app/Console/Commands/DiscordBotCommand.php
app/DiscordBotCommands/BaseCommand.php
app/DiscordBotCommands/MessageCommand.php [new file with mode: 0644]
app/Http/Controllers/DiscordBotController.php [new file with mode: 0644]
app/Models/DiscordBotCommand.php
app/Models/DiscordGuild.php
app/Policies/DiscordGuildPolicy.php
database/migrations/2025_07_02_095525_link_discord_bot_commands_and_guilds.php [new file with mode: 0644]
resources/js/components/common/DiscordChannelSelect.jsx
resources/js/components/discord-bot/ChannelControls.jsx [new file with mode: 0644]
resources/js/components/discord-bot/Controls.jsx
resources/js/components/twitch-bot/Controls.jsx
resources/js/i18n/de.js
resources/js/i18n/en.js
routes/api.php

index 18faf8d49bb72fb9a3fd77a68e93d29f1a8eb29d..769f284ca8cd6e3a9c66a10438d3eba6db32d82b 100644 (file)
@@ -60,6 +60,7 @@ class DiscordBotCommand extends Command
                        $discord->getLoop()->addPeriodicTimer(1, function () use ($discord) {
                                $command = CommandModel::where('status', '=', 'pending')->oldest()->first();
                                if ($command) {
+                                       $this->line('executing command '.$command->id.': '.$command->command);
                                        try {
                                                $command->execute($discord);
                                        } catch (\Exception $e) {
index e6861e03dad79666eaaeb726db05b8d48a862165..61152c1280d97be5455993c6053e07404333bea0 100644 (file)
@@ -3,6 +3,7 @@
 namespace App\DiscordBotCommands;
 
 use App\Models\DiscordBotCommand;
+use App\Models\DiscordGuild;
 use App\Models\Round;
 use App\Models\User;
 use Discord\Discord;
@@ -16,6 +17,8 @@ abstract class BaseCommand {
 
        public static function resolve(Discord $discord, DiscordBotCommand $cmd) {
                switch ($cmd->command) {
+                       case 'message':
+                               return new MessageCommand($discord, $cmd);
                        case 'presence':
                                return new PresenceCommand($discord, $cmd);
                        case 'result':
@@ -42,8 +45,13 @@ abstract class BaseCommand {
                if (isset($this->guild)) {
                        return \React\Promise\resolve($this->guild);
                }
+               if (is_null($this->command->discord_guild)) {
+                       $g = DiscordGuild::where('guild_id', '=', $this->command->tournament->discord)->firstOrFail();
+                       $this->command->discord_guild()->associate($g);
+                       $this->command->save();
+               }
                return $this->discord->guilds
-                       ->fetch($this->command->tournament->discord)
+                       ->fetch($this->command->discord_guild->guild_id)
                        ->then(function (Guild $guild) {
                                $this->guild = $guild;
                                if ($guild->preferred_locale && !($this->command->tournament && $this->command->tournament->locale)) {
@@ -64,6 +72,23 @@ abstract class BaseCommand {
                });
        }
 
+       protected function fetchParameterChannel() {
+               if (isset($this->parameterChannel)) {
+                       return \React\Promise\resolve($this->parameterChannel);
+               }
+               if (!$this->hasParameter('channel_id')) {
+                       throw new \Exception('missing channel_id parameter');
+               }
+               return $this->fetchGuild()
+                       ->then(function (Guild $guild) {
+                               return $guild->channels->fetch($this->getParameter('channel_id'));
+                       })
+                       ->then(function (Channel $channel) {
+                               $this->parameterChannel = $channel;
+                               return $channel;
+                       });
+       }
+
        protected function fetchRoundChannel() {
                if (isset($this->roundChannel)) {
                        return \React\Promise\resolve($this->roundChannel);
@@ -139,6 +164,7 @@ abstract class BaseCommand {
 
        protected $guild = null;
        protected $member = null;
+       protected $parameterChannel = null;
        protected $roundChannel = null;
        protected $user = null;
 
diff --git a/app/DiscordBotCommands/MessageCommand.php b/app/DiscordBotCommands/MessageCommand.php
new file mode 100644 (file)
index 0000000..746bd5f
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+namespace App\DiscordBotCommands;
+
+use App\Models\DiscordBotCommand;
+use Discord\Discord;
+use Discord\Parts\Channel\Channel;
+use Discord\Parts\Channel\Message;
+use Discord\Parts\User\Member;
+
+class MessageCommand extends BaseCommand {
+
+       public function __construct(Discord $discord, DiscordBotCommand $cmd) {
+               parent::__construct($discord, $cmd);
+       }
+
+       public function execute() {
+               if (!$this->hasParameter('text')) {
+                       throw new \Exception('missing text parameter');
+               }
+               return $this->fetchParameterChannel()
+                       ->then(function (Channel $channel) {
+                               $msg = $this->getParameter('text');
+                               return $channel->sendMessage($msg);
+                       });
+       }
+
+}
diff --git a/app/Http/Controllers/DiscordBotController.php b/app/Http/Controllers/DiscordBotController.php
new file mode 100644 (file)
index 0000000..99a1b4c
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\DiscordBotCommand;
+use App\Models\DiscordChannel;
+use App\Models\DiscordGuild;
+use Illuminate\Http\Request;
+
+class DiscordBotController extends Controller
+{
+
+       public function sendMessage(Request $request, DiscordGuild $guild) {
+               $this->authorize('manage', $guild);
+               $validatedData = $request->validate([
+                       'channel' => 'required|exists:App\\Models\\DiscordChannel,id',
+                       'text' => 'string',
+               ]);
+               $channel = DiscordChannel::findOrFail($validatedData['channel']);
+               $this->authorize('manage', $channel->guild);
+               $cmd = DiscordBotCommand::sendMessage($channel, $validatedData['text']);
+               return $cmd->toJson();
+       }
+
+}
index 7d7c3a88da96b6761a74e943474dd5ef1e6aaa78..8a8b81bc184092cfab9d2eaebdd95f60e4cbcede 100644 (file)
@@ -23,6 +23,23 @@ class DiscordBotCommand extends Model
                ];
                $cmd->status = 'pending';
                $cmd->save();
+               return $cmd;
+       }
+
+       public static function sendMessage(DiscordChannel $channel, $text, User $user = null) {
+               $cmd = new DiscordBotCommand();
+               $cmd->discord_guild_id = $channel->discord_guild_id;
+               if ($user) {
+                       $cmd->user()->associate($user);
+               }
+               $cmd->command = 'message';
+               $cmd->parameters = [
+                       'channel_id' => $channel->channel_id,
+                       'text' => $text,
+               ];
+               $cmd->status = 'pending';
+               $cmd->save();
+               return $cmd;
        }
 
        public static function syncUser($user_id) {
@@ -33,6 +50,11 @@ class DiscordBotCommand extends Model
                ];
                $cmd->status = 'pending';
                $cmd->save();
+               return $cmd;
+       }
+
+       public function discord_guild() {
+               return $this->belongsTo(DiscordGuild::class);
        }
 
        public function tournament() {
@@ -49,11 +71,10 @@ class DiscordBotCommand extends Model
                try {
                        BaseCommand::resolve($discord, $this)
                                ->execute()
-                               ->otherwise(function (\Throwable $e) {
-                                       $this->setException($e);
-                               })
                                ->done(function($v = null) {
                                        $this->setDone();
+                               }, function (\Throwable $e) {
+                                       $this->setException($e);
                                });
                } catch (\Exception $e) {
                        $this->setException($e);
index bcbd12dd32b835a4b71bcabf774a0e6a1e31f424..db0825aa7c3b38007218c0359b59c538420c233e 100644 (file)
@@ -44,6 +44,10 @@ class DiscordGuild extends Model
                $model->channels()->whereNotIn('channel_id', $channel_ids)->delete();
        }
 
+       public function bot_commands() {
+               return $this->hasMany(DiscordBotCommand::class)->orderBy('created_at', 'DESC');
+       }
+
        public function channels() {
                return $this->hasMany(DiscordChannel::class)->orderBy('position');
        }
index b4376564029e3418b901b73d1b6d659e1e8b8c33..285138ce5cd7fecfa8345c58e7887e6dc25f0ecd 100644 (file)
@@ -91,4 +91,17 @@ class DiscordGuildPolicy
        {
                return false;
        }
+
+       /**
+        * Determine whether the user can perform administrative tasks for the guild.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\DiscordGuild  $discordGuild
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function manage(User $user, DiscordGuild $discordGuild)
+       {
+               return $user->isAdmin() || $discordGuild->owner == $user->id;
+       }
+
 }
diff --git a/database/migrations/2025_07_02_095525_link_discord_bot_commands_and_guilds.php b/database/migrations/2025_07_02_095525_link_discord_bot_commands_and_guilds.php
new file mode 100644 (file)
index 0000000..31c7815
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+               Schema::table('discord_bot_commands', function (Blueprint $table) {
+                       $table->foreignId('discord_guild_id')->nullable()->default(null)->constrained();
+               });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+               Schema::table('discord_bot_commands', function (Blueprint $table) {
+                       $table->dropColumn('discord_guild_id');
+               });
+    }
+};
index 01ee3b0fa534a2c1cc8474ad7f6da775995924e5..653db1aa8c781f07a9ff174683448528c0a2ed52 100644 (file)
@@ -83,7 +83,7 @@ const DiscordChannelSelect = ({
                        <span>{resolved ? <ChannelBox channel={resolved} /> : value}</span>
                        <Button
                                className="ms-2"
-                               onClick={() => onChange({ guild: null, target: { name, value: '' }})}
+                               onClick={() => onChange({ channel: null, target: { name, value: '' }})}
                                title={t('button.unset')}
                                variant="outline-danger"
                        >
diff --git a/resources/js/components/discord-bot/ChannelControls.jsx b/resources/js/components/discord-bot/ChannelControls.jsx
new file mode 100644 (file)
index 0000000..ee06e66
--- /dev/null
@@ -0,0 +1,65 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+const ChannelControls = ({ channel, guild }) => {
+       const [messageText, setMessageText] = React.useState('');
+
+       const { t } = useTranslation();
+
+       const sendMessage = React.useCallback(async (text) => {
+               try {
+                       await axios.post(`/api/discord-bot/${guild.id}/send-message`, {
+                               channel: channel.id,
+                               text,
+                       });
+                       toastr.success(t('discordBot.messageSuccess'));
+               } catch (e) {
+                       toastr.error(t('discordBot.messageError'));
+               }
+       }, [channel, guild]);
+
+       return <section className="mt-5">
+               <h3>{t('discordBot.channelControls')}</h3>
+               <Row>
+                       <Col md={6}>
+                               <Form.Group>
+                                       <Form.Label>{t('discordBot.message')}</Form.Label>
+                                       <Form.Control
+                                               as="textarea"
+                                               onChange={({ target: { value } }) => {
+                                                       setMessageText(value);
+                                               }}
+                                               value={messageText}
+                                       />
+                                       <div className="button-bar">
+                                               <Button
+                                                       className="mt-2"
+                                                       disabled={!messageText}
+                                                       onClick={() => {
+                                                               if (messageText) sendMessage(messageText);
+                                                       }}
+                                                       variant="discord"
+                                               >
+                                                       {t('discordBot.sendMessage')}
+                                               </Button>
+                                       </div>
+                               </Form.Group>
+                       </Col>
+               </Row>
+       </section>;
+};
+
+ChannelControls.propTypes = {
+       channel: PropTypes.shape({
+               id: PropTypes.number,
+       }),
+       guild: PropTypes.shape({
+               id: PropTypes.number,
+       }),
+};
+
+export default ChannelControls;
index 408e314a182bcc8381965a0540241fb56ccb8363..12c2fb63d6d41d3b7aafbd341d600ddcaf24a13a 100644 (file)
@@ -2,11 +2,12 @@ import React from 'react';
 import { Col, Form, Row } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
 
+import ChannelControls from './ChannelControls';
 import DiscordChannelSelect from '../common/DiscordChannelSelect';
 import DiscordSelect from '../common/DiscordSelect';
 
 const Controls = () => {
-       const [channel, setChannel] = React.useState('');
+       const [channel, setChannel] = React.useState(null);
        const [guild, setGuild] = React.useState(null);
 
        const { t } = useTranslation();
@@ -27,15 +28,18 @@ const Controls = () => {
                                        <Form.Control
                                                as={DiscordChannelSelect}
                                                guild={guild.guild_id}
-                                               onChange={({ target: { value } }) => setChannel(value)}
+                                               onChange={({ channel }) => setChannel(channel)}
                                                types={[]}
-                                               value={channel}
+                                               value={channel ? channel.channel_id : ''}
                                        />
                                :
                                        <Form.Control plaintext readOnly defaultValue={t('discordBot.selectGuild')} />
                                }
                        </Form.Group>
                </Row>
+               {guild && channel ?
+                       <ChannelControls channel={channel} guild={guild} />
+               : null}
        </>;
 };
 
index 394f5464c6369ec4049d342861a287396bb468f1..55cbd00b1d51ad26c157543bd6375ff64b88af1a 100644 (file)
@@ -54,7 +54,7 @@ const Controls = () => {
                } catch (e) {
                        toastr.error(t('twitchBot.chatError'));
                }
-       }, [channel, chatText, t]);
+       }, [channel, t]);
 
        const randomChat = React.useCallback(async (category) => {
                try {
@@ -66,7 +66,7 @@ const Controls = () => {
                } catch (e) {
                        toastr.error(t('twitchBot.chatError'));
                }
-       }, [channel, chatText, t]);
+       }, [channel, t]);
 
        const adlibChat = React.useCallback(async () => {
                try {
@@ -78,7 +78,7 @@ const Controls = () => {
                } catch (e) {
                        toastr.error(t('twitchBot.chatError'));
                }
-       }, [channel, chatText, t]);
+       }, [channel, t]);
 
        const join = React.useCallback(async (bot_nick) => {
                try {
index 6c171323e53768024dda55b28cfef017156017b9..fc4b2952b9ebf73a4bcf1c1126a5692e997c715f 100644 (file)
@@ -139,11 +139,16 @@ export default {
                },
                discordBot: {
                        channel: 'Kanal',
+                       channelControls: 'Kanal-Steuerung',
                        controls: 'Steuerung',
                        guild: 'Server',
                        heading: 'Discord Bot',
                        invite: 'Bot einladen',
+                       message: 'Nachricht',
+                       messageError: 'Fehler beim Senden',
+                       messageSuccess: 'Nachricht in Warteschlange',
                        selectGuild: 'Bitte Server wählen',
+                       sendMessage: 'Nachricht senden'
                },
                episodes: {
                        addRestream: 'Neuer Restream',
index 1341118c185a01f8e316e91267e4d0a0e7369fca..b2175c5cc10d6b815b5b08a4a92e06e34d7e9943 100644 (file)
@@ -139,11 +139,16 @@ export default {
                },
                discordBot: {
                        channel: 'Channel',
+                       channelControls: 'Channel controls',
                        controls: 'Controls',
                        guild: 'Server',
                        heading: 'Discord Bot',
                        invite: 'Invite bot',
+                       message: 'Message',
+                       messageError: 'Error sending message',
+                       messageSuccess: 'Message queued',
                        selectGuild: 'Please select server',
+                       sendMessage: 'Send message',
                },
                episodes: {
                        addRestream: 'Add Restream',
index b8f203309f47e38cc8c5b46b506aa230f299211e..94280659e50780b9e297a54cfa1f6bdf986cfc68 100644 (file)
@@ -46,6 +46,8 @@ 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');
 
+Route::post('discord-bot/{guild}/send-message', 'App\Http\Controllers\DiscordBotController@sendMessage');
+
 Route::get('discord-channels/{channel_id}', 'App\Http\Controllers\DiscordChannelController@single');
 
 Route::get('discord-guilds', 'App\Http\Controllers\DiscordGuildController@search');