]> git.localhorst.tv Git - alttp.git/commitdiff
discord guild channel subscriptions
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 28 Aug 2025 17:10:48 +0000 (19:10 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 28 Aug 2025 17:10:48 +0000 (19:10 +0200)
app/Console/Commands/DiscordEpisodeSubscriptionsCommand.php
app/Http/Controllers/DiscordGuildController.php
app/Models/DiscordGuild.php
app/Models/DiscordGuildChannelSubscription.php [new file with mode: 0644]
database/migrations/2025_08_28_161722_create_discord_guild_channel_subscriptions_table.php [new file with mode: 0644]
resources/js/components/common/ChannelSelect.jsx
resources/js/components/discord-bot/ChannelSubscriptions.jsx [new file with mode: 0644]
resources/js/components/discord-bot/GuildControls.jsx
resources/js/i18n/de.js
resources/js/i18n/en.js

index 8c59b6f39d60891ee4a7a856c2a70b79751eb6c0..5b201655ed91027b9645012bc271780c717096ea 100644 (file)
@@ -44,6 +44,7 @@ class DiscordEpisodeSubscriptionsCommand extends Command
        private function handleGuild(DiscordGuild $guild): void {
                $from = now()->sub(1, 'hour');
                $until = now()->add(14, 'days');
+               $channelIDs = $guild->channel_subscriptions->pluck('channel_id')->toArray();
                $eventIDs = $guild->event_subscriptions->pluck('event_id')->toArray();
                $modeIDs = $guild->ladder_subscriptions->pluck('step_ladder_mode_id')->toArray();
                $userIDs = $guild->user_subscriptions->pluck('user_id')->toArray();
@@ -57,9 +58,14 @@ class DiscordEpisodeSubscriptionsCommand extends Command
                        ->where('episodes.confirmed', '=', true)
                        ->orderBy('episodes.start', 'ASC')
                        ->limit(20);
-               $query->where(function ($subquery) use ($eventIDs, $modeIDs, $userIDs) {
+               $query->where(function ($subquery) use ($channelIDs, $eventIDs, $modeIDs, $userIDs) {
+                       if (!empty($channelIDs)) {
+                               $subquery->orWhereHas('channels', function ($builder) use ($channelIDs) {
+                                       $builder->whereIn('channel_episode.channel_id', $channelIDs);
+                               });
+                       }
                        if (!empty($eventIDs)) {
-                               $subquery->whereIn('episodes.event_id', $eventIDs);
+                               $subquery->orWhereIn('episodes.event_id', $eventIDs);
                        }
                        if (!empty($modeIDs)) {
                                $subquery->orWhereIn('episodes.step_ladder_mode_id', $modeIDs);
index 979c675a8659a888418357a1f6a954afcca21276..59c09045326f3049417b81ff2d53d0b2c22a45e0 100644 (file)
@@ -67,6 +67,8 @@ class DiscordGuildController extends Controller
 
        public function subscriptions(Request $request, $guild_id) {
                $guild = DiscordGuild::with([
+                       'channel_subscriptions',
+                       'channel_subscriptions.channel',
                        'event_subscriptions',
                        'event_subscriptions.event',
                        'ladder_subscriptions',
@@ -83,14 +85,19 @@ class DiscordGuildController extends Controller
                $this->authorize('manage', $guild);
 
                $validatedData = $request->validate([
+                       'add_channel' => 'numeric|exists:App\Models\Channel,id',
                        'add_event' => 'numeric|exists:App\Models\Event,id',
                        'add_ladder' => 'numeric|exists:App\Models\StepLadderMode,id',
                        'add_user' => 'numeric|exists:App\Models\User,id',
+                       'remove_channel' => 'numeric|exists:App\Models\Channel,id',
                        'remove_event' => 'numeric|exists:App\Models\Event,id',
                        'remove_ladder' => 'numeric|exists:App\Models\StepLadderMode,id',
                        'remove_user' => 'numeric|exists:App\Models\User,id',
                ]);
 
+               if (isset($validatedData['add_channel'])) {
+                       $guild->channel_subscriptions()->create(['channel_id' => $validatedData['add_channel']]);
+               }
                if (isset($validatedData['add_event'])) {
                        $guild->event_subscriptions()->create(['event_id' => $validatedData['add_event']]);
                }
@@ -100,6 +107,10 @@ class DiscordGuildController extends Controller
                if (isset($validatedData['add_user'])) {
                        $guild->user_subscriptions()->create(['user_id' => $validatedData['add_user']]);
                }
+               if (isset($validatedData['remove_channel'])) {
+                       $sub = $guild->channel_subscriptions()->where('channel_id', '=', $validatedData['remove_channel'])->firstOrFail();
+                       $sub->delete();
+               }
                if (isset($validatedData['remove_event'])) {
                        $sub = $guild->event_subscriptions()->where('event_id', '=', $validatedData['remove_event'])->firstOrFail();
                        $sub->delete();
@@ -113,6 +124,8 @@ class DiscordGuildController extends Controller
                        $sub->delete();
                }
                $guild->load([
+                       'channel_subscriptions',
+                       'channel_subscriptions.channel',
                        'event_subscriptions',
                        'event_subscriptions.event',
                        'ladder_subscriptions',
index bfbd2d5d7b9ecc1c9682969b55c527ee7eff8d93..134099fc1d62f1211898d85016583ce0af2718ed 100644 (file)
@@ -57,6 +57,10 @@ class DiscordGuild extends Model {
                return $this->hasMany(DiscordGuildCrew::class);
        }
 
+       public function channel_subscriptions() {
+               return $this->hasMany(DiscordGuildChannelSubscription::class);
+       }
+
        public function event_subscriptions() {
                return $this->hasMany(DiscordGuildEventSubscription::class);
        }
@@ -78,6 +82,13 @@ class DiscordGuild extends Model {
                $any = count($this->event_subscriptions) + count($this->ladder_subscriptions) + count($this->user_subscriptions);
                $title = 'Creating events for:';
                $text = '';
+               if (count($this->channel_subscriptions)) {
+                       $text .= "Channels:\n";
+                       foreach ($this->channel_subscriptions as $csub) {
+                               $text .= '- ['.$csub->channel->title.']('.$csub->channel->stream_link.")\n";
+                       }
+                       $text .= "\n";
+               }
                if (count($this->event_subscriptions)) {
                        $text .= "Events:\n";
                        foreach ($this->event_subscriptions as $esub) {
diff --git a/app/Models/DiscordGuildChannelSubscription.php b/app/Models/DiscordGuildChannelSubscription.php
new file mode 100644 (file)
index 0000000..d5e5734
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
+use Illuminate\Database\Eloquent\Model;
+
+class DiscordGuildChannelSubscription extends Model {
+
+       use BroadcastsEvents;
+
+       public function broadcastOn(string $event): array {
+               $channels = [];
+               if ($this->discord_guild_id) {
+                       $channels[] = new PrivateChannel('DiscordGuild.'.$this->discord_guild_id);
+               }
+               return $channels;
+       }
+
+       public function channel() {
+               return $this->belongsTo(Channel::class);
+       }
+
+       public function guild() {
+               return $this->belongsTo(DiscordGuild::class);
+       }
+
+       protected $fillable = [
+               'channel_id',
+               'discord_guild_id',
+       ];
+
+       protected $with = [
+               'channel',
+       ];
+
+}
diff --git a/database/migrations/2025_08_28_161722_create_discord_guild_channel_subscriptions_table.php b/database/migrations/2025_08_28_161722_create_discord_guild_channel_subscriptions_table.php
new file mode 100644 (file)
index 0000000..18b03e0
--- /dev/null
@@ -0,0 +1,27 @@
+<?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::create('discord_guild_channel_subscriptions', function (Blueprint $table) {
+                       $table->id();
+                       $table->foreignId('discord_guild_id')->constrained();
+                       $table->foreignId('channel_id')->constrained();
+                       $table->timestamps();
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        */
+       public function down(): void {
+               Schema::dropIfExists('discord_guild_channel_subscriptions');
+       }
+};
index 61b34e9adcf08db96d2ed4a46e4f4a6abee8fbff..a9a90d45cf010aeb9cfa5c39d02efa4c94e38f15 100644 (file)
@@ -115,10 +115,13 @@ const ChannelSelect = ({
                                                <ListGroup.Item
                                                        action
                                                        key={result.id}
-                                                       onClick={() => onChange({
-                                                               channel: result,
-                                                               target: { value: result.id },
-                                                       })}
+                                                       onClick={() => {
+                                                               onChange({
+                                                                       target: { name, value: result.id },
+                                                               });
+                                                               setSearch('');
+                                                               setShowResults(false);
+                                                       }}
                                                >
                                                        {result.title}
                                                </ListGroup.Item>
diff --git a/resources/js/components/discord-bot/ChannelSubscriptions.jsx b/resources/js/components/discord-bot/ChannelSubscriptions.jsx
new file mode 100644 (file)
index 0000000..a659c40
--- /dev/null
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import ChannelSelect from '../common/ChannelSelect';
+import Icon from '../common/Icon';
+import { mayManageGuild } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const ChannelSubscriptions = ({ addChannel, guild, removeChannel, subs }) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       const mayManage = React.useMemo(() => mayManageGuild(user, guild), [guild, user]);
+
+       return <div>
+               {mayManage ?
+                       <Form.Group controlId="csubs.addChannel">
+                               <Form.Label>{t('discordBot.addChannel')}</Form.Label>
+                               <Form.Control
+                                       as={ChannelSelect}
+                                       excludeIds={subs.map(s => s.channel_id)}
+                                       onChange={e => addChannel(e.target.value)}
+                                       value=""
+                               />
+                       </Form.Group>
+               : null}
+               {subs.map((csub) => (
+                       <div className="d-flex align-items-center justify-content-between my-2" key={csub.id}>
+                               <div>{csub.channel.title}</div>
+                               {mayManage ?
+                                       <div className="button-bar">
+                                               <Button
+                                                       onClick={() => removeChannel(csub.channel_id)}
+                                                       size="sm"
+                                                       title={t('button.remove')}
+                                                       variant="outline-danger"
+                                               >
+                                                       <Icon.DELETE title="" />
+                                               </Button>
+                                       </div>
+                               : null}
+                       </div>
+               ))}
+       </div>;
+};
+
+ChannelSubscriptions.propTypes = {
+       addChannel: PropTypes.func,
+       guild: PropTypes.shape({
+       }),
+       removeChannel: PropTypes.func,
+       subs: PropTypes.arrayOf(PropTypes.shape({
+       })),
+};
+
+export default ChannelSubscriptions;
index 499d77d510691abf6b7e5eada3ab7302d493b4fc..fa86db45d9a9eba9faa6ebb8a0e524a46656fd9a 100644 (file)
@@ -5,6 +5,7 @@ import { Col, Row } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
 import toastr from 'toastr';
 
+import ChannelSubscriptions from './ChannelSubscriptions';
 import EventSubscriptions from './EventSubscriptions';
 import GuildCrew from './GuildCrew';
 import GuildProtocol from './GuildProtocol';
@@ -53,6 +54,17 @@ const GuildControls = ({ guild, patchGuild }) => {
                }
        }, [guild.guild_id, patchGuild, t]);
 
+       const addChannelSub = React.useCallback(async (channel_id) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
+                               add_channel: channel_id,
+                       });
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.channelSubError', { error }));
+               }
+       }, [guild.guild_id, patchGuild, t]);
+
        const addEventSub = React.useCallback(async (event_id) => {
                try {
                        const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
@@ -64,6 +76,17 @@ const GuildControls = ({ guild, patchGuild }) => {
                }
        }, [guild.guild_id, patchGuild, t]);
 
+       const removeChannelSub = React.useCallback(async (channel_id) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
+                               remove_channel: channel_id,
+                       });
+                       patchGuild(response.data);
+               } catch (error) {
+                       toastr.error(t('discordBot.channelUnsubError', { error }));
+               }
+       }, [guild.guild_id, patchGuild, t]);
+
        const removeEventSub = React.useCallback(async (event_id) => {
                try {
                        const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
@@ -163,6 +186,21 @@ const GuildControls = ({ guild, patchGuild }) => {
                                        patchGuild(g => ({ ...g, crew: (g.crew || []).filter(c => c.id !== e.model.id) }));
                                }
                        })
+                       .listen('.DiscordGuildChannelSubscriptionCreated', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, channel_subscriptions: [...g.channel_subscriptions || [], e.model] }));
+                               }
+                       })
+                       .listen('.DiscordGuildChannelSubscriptionUpdated', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, channel_subscriptions: (g.channel_subscriptions || []).map(c => c.id === e.model.id ? { ...c, ...e.model } : c) }));
+                               }
+                       })
+                       .listen('.DiscordGuildChannelSubscriptionDeleted', e => {
+                               if (e.model) {
+                                       patchGuild(g => ({ ...g, channel_subscriptions: (g.channel_subscriptions || []).filter(c => c.id !== e.model.id) }));
+                               }
+                       })
                        .listen('.DiscordGuildEventSubscriptionCreated', e => {
                                if (e.model) {
                                        patchGuild(g => ({ ...g, event_subscriptions: [...g.event_subscriptions || [], e.model] }));
@@ -264,9 +302,21 @@ const GuildControls = ({ guild, patchGuild }) => {
                                                />
                                        </ErrorBoundary>
                                </Col>
+                               <Col className="my-5" md={6}>
+                                       <h3>{t('discordBot.channelSubscriptions')}</h3>
+                                       <p style={{ minHeight: '6.5em' }}>{t('discordBot.channelSubscriptionDescription')}</p>
+                                       <ErrorBoundary>
+                                               <ChannelSubscriptions
+                                                       addChannel={addChannelSub}
+                                                       guild={guild}
+                                                       removeChannel={removeChannelSub}
+                                                       subs={guild.channel_subscriptions || []}
+                                               />
+                                       </ErrorBoundary>
+                               </Col>
                                <Col className="my-5" md={6}>
                                        <h3>{t('discordBot.ladderSubscriptions')}</h3>
-                                       <p style={{ minHeight: '3.5em' }}>{t('discordBot.ladderSubscriptionDescription')}</p>
+                                       <p style={{ minHeight: '6.5em' }}>{t('discordBot.ladderSubscriptionDescription')}</p>
                                        <ErrorBoundary>
                                                <LadderSubscriptions
                                                        addMode={addLadderSub}
@@ -289,6 +339,8 @@ const GuildControls = ({ guild, patchGuild }) => {
 
 GuildControls.propTypes = {
        guild: PropTypes.shape({
+               channel_subscriptions: PropTypes.arrayOf(PropTypes.shape({
+               })),
                event_subscriptions: PropTypes.arrayOf(PropTypes.shape({
                })),
                id: PropTypes.number,
index a28d86db75b30d552012a032dd2ce62d288faf83..c083c53b316cbf847d7f2d0f2313eb070462617f 100644 (file)
@@ -139,6 +139,7 @@ export default {
                },
                discordBot: {
                        addCrew: 'User hinzufügen',
+                       addChannel: 'Kanal abonnieren',
                        addEvent: 'Event abonnieren',
                        addLadderMode: 'Ladder Mode abonnieren',
                        addUser: 'User abonnieren',
@@ -170,6 +171,10 @@ export default {
                        crewChangeError: 'Fehler beim Speichern',
                        crewRemoveError: 'Fehler beim Entfernen',
                        description: 'Dieser Bot kann automatisch Discord Events für abonnierte Veranstaltungen und Benutzer erstellen. Er ist auch in der Lage, Diskussions-Kanäle für Async Turniere zu verwalten.',
+                       channelSubError: 'Fehler beim Abonnieren',
+                       channelSubscriptionDescription: 'Restreams auf abonnierten Kanälen werden als Event in Discord angelegt.',
+                       channelSubscriptions: 'Abonnierte Kanäle',
+                       channelUnsubError: 'Fehler beim Kündigen',
                        eventSubError: 'Fehler beim Abonnieren',
                        eventSubscriptionDescription: 'Episoden abonnierter Veranstaltungen werden als Event in Discord angelegt.',
                        eventSubscriptions: 'Abonnierte Veranstaltungen',
index fd7d9f6762ecbe97bfe6ba38b74d303ce5ae7390..61ae69e626f6f99657d2b961a2b46a4a0cc27498 100644 (file)
@@ -139,6 +139,7 @@ export default {
                },
                discordBot: {
                        addCrew: 'Add user',
+                       addChannel: 'Subscribe to channel',
                        addEvent: 'Subscribe to event',
                        addLadderMode: 'Subscribe to ladder mode',
                        addUser: 'Subscribe to user',
@@ -170,6 +171,10 @@ export default {
                        crewChangeError: 'Error modifying user',
                        crewRemoveError: 'Error removing user',
                        description: 'This bot can automatically create scheduled events on your discord for events and users your discord is subscribed to. It also manages creation and access for async tournament discussion channels.',
+                       channelSubError: 'Error subscribing',
+                       channelSubscriptionDescription: 'Restreams on subscribed channels will be posted as discord scheduled events.',
+                       channelSubscriptions: 'Channel subscriptions',
+                       channelUnsubError: 'Error unsubscribing',
                        eventSubError: 'Error subscribing',
                        eventSubscriptionDescription: 'Episodes of subscribed events will be posted as discord scheduled events.',
                        eventSubscriptions: 'Event subscriptions',