]> git.localhorst.tv Git - alttp.git/commitdiff
rough thread supports for round channels
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 28 Nov 2025 12:12:53 +0000 (13:12 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 28 Nov 2025 13:13:36 +0000 (14:13 +0100)
app/Console/Commands/DiscordBotCommand.php
app/DiscordBotCommands/BaseCommand.php
app/DiscordBotCommands/ResultCommand.php
app/Http/Controllers/DiscordChannelController.php
resources/js/components/common/DiscordChannelSelect.jsx
resources/js/components/tournament/DiscordForm.jsx
resources/js/helpers/discord.js
resources/js/i18n/de.js
resources/js/i18n/en.js

index c6dce9aea36719d435f3b5b6ea807bb9925fede4..4abbe92be222857b19c4b457e54e9eae095b22df 100644 (file)
@@ -8,8 +8,10 @@ use App\Models\DiscordBotCommand as CommandModel;
 use App\Models\DiscordChannel;
 use App\Models\DiscordGuild;
 use App\Models\DiscordRole;
+use App\Models\Tournament;
 use Discord\Discord;
 use Discord\Parts\Channel\Channel;
+use Discord\Parts\Channel\Message;
 use Discord\Parts\Guild\Guild;
 use Discord\Parts\Guild\Role;
 use Discord\Parts\User\Activity;
@@ -134,6 +136,31 @@ class DiscordBotCommand extends Command
                                $this->error('guild role delete: '.$e->getMessage());
                        }
                });
+               $discord->on(Event::MESSAGE_CREATE, function (Message $message, Discord $discord) {
+                       // did I send this?
+                       if ($message->user_id != $discord->id) {
+                               return;
+                       }
+                       // is it thread creation?
+                       if ($message->type != 18) {
+                               return;
+                       }
+                       // was it sent on a guild channel?
+                       if (!$message->guild_id || !$message->channel_id) {
+                               return;
+                       }
+                       // does it belong to a tournament
+                       $tournament = Tournament::query()
+                               ->where('locked', '=', '0')
+                               ->where('discord', '=', $message->guild_id)
+                               ->where('discord_round_category', '=', $message->channel_id)
+                               ->first();
+                       if (!$tournament) {
+                               return;
+                       }
+                       // then begone with it
+                       $message->delete();
+               });
                $discord->getLoop()->addSignal(SIGINT, function () use ($discord) {
                        $discord->close();
                });
index af647ce7dd76ac7b77f4446215caa3f8be5a3974..e6cc945a67e27b26c1c8d7612622077e29c72ff3 100644 (file)
@@ -2,8 +2,8 @@
 
 namespace App\DiscordBotCommands;
 
-use App\Models\ChannelCrew;
 use App\Models\DiscordBotCommand;
+use App\Models\DiscordChannel;
 use App\Models\DiscordGuild;
 use App\Models\Episode;
 use App\Models\EpisodeCrew;
@@ -12,6 +12,7 @@ use App\Models\User;
 use Discord\Discord;
 use Discord\Parts\Channel\Channel;
 use Discord\Parts\Guild\Guild;
+use Discord\Parts\Thread\Thread;
 use Discord\Parts\User\Member;
 use Discord\Parts\User\User as DiscordUser;
 use Illuminate\Support\Facades\App;
@@ -108,24 +109,44 @@ abstract class BaseCommand {
                if (isset($this->roundChannel)) {
                        return \React\Promise\resolve($this->roundChannel);
                }
+               $parent = $this->getRoundChannelParent();
+               if (!$parent || $parent->type == 4) {
+                       return $this->fetchGuild()
+                               ->then(function (Guild $guild) {
+                                       $channel = $guild->channels->find(function (Channel $c) {
+                                               return $c->name == $this->getRoundChannelName();
+                                       });
+                                       if ($channel) {
+                                               return $channel;
+                                       }
+                                       $channel = $guild->channels->create([
+                                               'name' => $this->getRoundChannelName(),
+                                               'is_private' => true,
+                                               'parent_id' => $this->command->tournament->discord_round_category,
+                                       ]);
+                                       return $guild->channels->save($channel);
+                               })
+                               ->then(function (Channel $channel) {
+                                       $this->roundChannel = $channel;
+                                       return $channel;
+                               });
+               }
                return $this->fetchGuild()
-                       ->then(function (Guild $guild) {
-                               $channel = $guild->channels->find(function (Channel $c) {
-                                       return $c->name == $this->getRoundChannelName();
+                       ->then(function (Guild $guild) use ($parent) {
+                               return $guild->channels->fetch($parent->channel_id);
+                       })
+                       ->then(function (Channel $channel) {
+                               $thread = $channel->threads->find(function (Thread $t) {
+                                       return $t->name == $this->getRoundChannelName();
                                });
-                               if ($channel) {
-                                       return $channel;
+                               if ($thread) {
+                                       return $thread;
                                }
-                               $channel = $guild->channels->create([
-                                       'name' => $this->getRoundChannelName(),
-                                       'is_private' => true,
-                                       'parent_id' => $this->command->tournament->discord_round_category,
-                               ]);
-                               return $guild->channels->save($channel);
+                               return $channel->startThread($this->getRoundChannelName(), $channel->guild->premium_tier >= 2);
                        })
-                       ->then(function (Channel $channel) {
-                               $this->roundChannel = $channel;
-                               return $channel;
+                       ->then(function (Thread $thread) {
+                               $this->roundChannel = $thread;
+                               return $thread;
                        });
        }
 
@@ -169,7 +190,19 @@ abstract class BaseCommand {
 
        protected function getRoundChannelName() {
                $round = $this->getRound();
-               return sprintf($this->command->tournament->discord_round_template, $round->number);
+               $replace = [
+                       '%d' => $round->number,
+                       '{group}' => $round->group,
+                       '{number}' => $round->number,
+                       '{title}' => $round->title,
+               ];
+               return trim(str_replace(array_keys($replace), array_values($replace), $this->command->tournament->discord_round_template));
+       }
+
+       protected function getRoundChannelParent(): DiscordChannel|null {
+               return DiscordChannel::query()
+                       ->where('channel_id', '=', $this->command->tournament->discord_round_category)
+                       ->first();
        }
 
        protected function getUser() {
index 3f2030e13e4108d4fd6d0a149f64576eb30c1859..211e23bcee07edfea43bf789d296b6cbca22f648 100644 (file)
@@ -5,6 +5,7 @@ namespace App\DiscordBotCommands;
 use App\Models\DiscordBotCommand;
 use Discord\Discord;
 use Discord\Parts\Channel\Channel;
+use Discord\Parts\Thread\Thread;
 use Discord\Parts\User\Member;
 use React\Promise\PromiseInterface;
 
@@ -19,15 +20,7 @@ class ResultCommand extends BaseCommand {
                        return \React\Promise\resolve();
                }
                return $this->fetchRoundChannel()
-                       ->then(function (Channel $channel) {
-                               return $this->fetchMember();
-                       })
-                       ->then(function (Member $member) {
-                               return $this->roundChannel->setPermissions($member, [
-                                       'view_channel',
-                               ]);
-                       })
-                       ->then(function () {
+                       ->then(function (Channel|Thread $channel) {
                                $user = $this->getUser();
                                $round = $this->getRound();
                                $result = $user->findResult($round);
@@ -37,7 +30,19 @@ class ResultCommand extends BaseCommand {
                                } else {
                                        $msg = __('discord_commands.result.finish', ['name' => $user->getName(), 'time' => $result->formatTime()]);
                                }
-                               return $this->roundChannel->sendMessage($msg);
+                               return $channel->sendMessage($msg);
+                       })
+                       ->then(function () {
+                               return $this->fetchMember();
+                       })
+                       ->then(function (Member $member) {
+                               if ($this->roundChannel instanceof Channel) {
+                                       return $this->roundChannel->setPermissions($member, [
+                                               'view_channel',
+                                       ]);
+                               } else {
+                                       return $this->roundChannel->addMember($member);
+                               }
                        });
        }
 
index 20921cec0399890bb1202c4149504a103913c185..de4ff543853ed6d203f83153b59bd0a6caa94895 100644 (file)
@@ -23,14 +23,15 @@ class DiscordChannelController extends Controller
 
                $channels = $guild->channels();
                if (!empty($validatedData['parents'])) {
-                       $channels = $channels->whereIn('parent', $validatedData['parents']);
+                       $channels->whereIn('parent', $validatedData['parents']);
                }
                if (!empty($validatedData['phrase'])) {
-                       $channels = $channels->where('name', 'LIKE', '%'.$validatedData['phrase'].'%');
+                       $channels->where('name', 'LIKE', '%'.$validatedData['phrase'].'%');
                }
                if (!empty($validatedData['types'])) {
-                       $channels = $channels->whereIn('type', $validatedData['types']);
+                       $channels->whereIn('type', $validatedData['types']);
                }
+               $channels->orderBy('position');
                return $channels->get()->toJson();
        }
 
index f72662066aca95f75a6e88abc3f84f9350d4140e..737a32df29cce119bf4f902b2b101008cab2e675 100644 (file)
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
 import Icon from './Icon';
 import ChannelBox from '../discord-guilds/ChannelBox';
 import debounce from '../../helpers/debounce';
+import { sortChannels } from '../../helpers/discord';
 
 const DiscordChannelSelect = ({
        guild,
@@ -52,7 +53,7 @@ const DiscordChannelSelect = ({
                                signal: ctrl.signal,
                        });
                        ctrl = null;
-                       setResults(response.data);
+                       setResults(sortChannels(response.data));
                } catch (e) {
                        ctrl = null;
                        console.error(e);
index d896504c60bab62d6c0617d68ccc8e34fcdf1c51..c59b5bb3809889b40d7dd4915dea4e04542b1246 100644 (file)
@@ -3,7 +3,7 @@ import { withFormik } from 'formik';
 import PropTypes from 'prop-types';
 import React from 'react';
 import { Button, Form } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import toastr from 'toastr';
 
 import DiscordChannelSelect from '../common/DiscordChannelSelect';
@@ -19,42 +19,48 @@ const DiscordForm = ({
        touched,
        tournament,
        values,
-}) =>
-<Form noValidate onSubmit={handleSubmit}>
-       <fieldset>
-               <legend>{i18n.t('tournaments.discordSettings')}</legend>
-               <Form.Group controlId="tournament.discord_round_category">
-                       <Form.Label>
-                               {i18n.t('tournaments.discordRoundCategory')}
-                       </Form.Label>
-                       <DiscordChannelSelect
-                               guild={tournament.discord}
-                               isInvalid={!!(touched.round_category && errors.round_category)}
-                               name="round_category"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               types={[4]}
-                               value={values.round_category || ''}
-                       />
-               </Form.Group>
-               <Form.Group controlId="tournament.discord_round_template">
-                       <Form.Label>
-                               {i18n.t('tournaments.discordRoundTemplate')}
-                       </Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.round_template && errors.round_template)}
-                               name="round_template"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.round_template || ''}
-                       />
-               </Form.Group>
-               <Button className="mt-3" type="submit" variant="primary">
-                       {i18n.t('button.save')}
-               </Button>
-       </fieldset>
-</Form>;
+}) => {
+       const { t } = useTranslation();
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <fieldset>
+                       <legend>{t('tournaments.discordSettings')}</legend>
+                       <Form.Group controlId="tournament.discord_round_category">
+                               <Form.Label>
+                                       {t('tournaments.discordRoundCategory')}
+                               </Form.Label>
+                               <DiscordChannelSelect
+                                       guild={tournament.discord}
+                                       isInvalid={!!(touched.round_category && errors.round_category)}
+                                       name="round_category"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       types={[0, 4]}
+                                       value={values.round_category || ''}
+                               />
+                       </Form.Group>
+                       <Form.Group controlId="tournament.discord_round_template">
+                               <Form.Label>
+                                       {t('tournaments.discordRoundTemplate')}
+                               </Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.round_template && errors.round_template)}
+                                       name="round_template"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.round_template || ''}
+                               />
+                               <Form.Text muted>
+                                       {t('tournaments.discordRoundTemplateDescription')}
+                               </Form.Text>
+                       </Form.Group>
+                       <Button className="mt-3" type="submit" variant="primary">
+                               {t('button.save')}
+                       </Button>
+               </fieldset>
+       </Form>;
+};
 
 DiscordForm.propTypes = {
        errors: PropTypes.shape({
@@ -105,4 +111,4 @@ export default withFormik({
                round_category: yup.string(),
                round_template: yup.string(),
        }),
-})(withTranslation()(DiscordForm));
+})(DiscordForm);
index bf0e349ecd5b0a4b78a28ef078a5bc7d06278a95..88558895e801b441b0d471d6f25f3aed9b76efce 100644 (file)
@@ -4,3 +4,50 @@ const scope = 'bot+applications.commands';
 // Manage Roles, Manage Channels, View Channels, Manage Events, Create Events
 const permissions = '17601044415504';
 export const INVITE_URL = `${authEndpoint}?client_id=${clientId}&scope=${scope}&permissions=${permissions}`;
+
+const compareChannelType = (a, b) => {
+       if (a.type === b.type) return 0;
+       if (a.type === 5) return -1;
+       if (b.type === 5) return 1;
+       if (a.type === 0) return -1;
+       if (b.type === 0) return 1;
+       return 0;
+}
+
+const compareChannelPosition = (a, b) => a.position - b.position;
+
+const compareChannel = (a, b) => {
+       return compareChannelType(a, b) || compareChannelPosition(a, b);
+};
+
+const flattenChannels = (channels) => {
+       channels.sort(compareChannel);
+       const flat = [];
+       channels.forEach((channel) => {
+               flat.push(channel);
+               if (channel.children) {
+                       flat.push(...flattenChannels(channel.children));
+               }
+       })
+       return flat;
+}
+
+export const sortChannels = (channels) => {
+       const parents = [];
+       channels.forEach((channel) => {
+               if (!channel.parent_id) {
+                       parents.push(channel);
+                       return;
+               }
+               const parent = channels.find((c) => c.channel_id === channel.parent_id);
+               if (!parent) {
+                       parents.push(channel);
+                       return;
+               }
+               if (!parent.children) {
+                       parent.children = [];
+               }
+               parent.children.push(channel);
+       });
+       return flattenChannels(parents);
+};
index 13b5cf9ff2ba3fc08f549a4e1bf6d8c9a98d9e33..b1fedd9ab2de39a4ebf76fc46bfc6ad806f15cb7 100644 (file)
@@ -916,6 +916,7 @@ export default {
                        discordNoCategory: 'Keine Kategorie',
                        discordRoundCategory: 'Kategorie für Runden-Kanäle',
                        discordRoundTemplate: 'Template für Runden-Kanäle',
+                       discordRoundTemplateDescription: 'Verfügbare Platzhalter: {group}, {number}, {title}. Leer lassen zum deaktivieren. Großbuchstaben sowie Leerzeichen lässt Discord nur bei Threads zu.',
                        discordSettings: 'Discord Einstellungen',
                        discordSettingsError: 'Fehler beim Speichern der Discord Einstellungen',
                        discordSettingsSuccess: 'Discord Einstellungen gespeichert',
index 19ecf16683537985e152f430ecb1144e9fb224c5..4e3b211af1cc983d3d2eec2480e9b21dade423b5 100644 (file)
@@ -916,6 +916,7 @@ export default {
                        discordNoCategory: 'No category',
                        discordRoundCategory: 'Category for round channels',
                        discordRoundTemplate: 'Template for round channels',
+                       discordRoundTemplateDescription: 'Available replacements: {group}, {number}, {title}. Leave empty to disable. Discord will not allow capital letters and spaces in channels, only threads.',
                        discordSettings: 'Discord settings',
                        discordSettingsError: 'Error saving discord settings',
                        discordSettingsSuccess: 'Discord settings saved',