diff --git a/frontend/src/overrides.css b/frontend/src/overrides.css index ad699e5..bf3879d 100644 --- a/frontend/src/overrides.css +++ b/frontend/src/overrides.css @@ -51,6 +51,16 @@ body .button.is-success { color:#879799; } +.customcommand .card-header-title code { + background-color: transparent; + padding-right: 1em; +} + +.customcommand .content blockquote { + padding: 10px 20px; + margin: 5px; +} + /* Nice expand/contract icon without FontAwesome! */ .icon.expand-on, .icon.expand-off { transition: all 50ms; diff --git a/frontend/src/store/api/reducer.ts b/frontend/src/store/api/reducer.ts index c6c6315..050eb03 100644 --- a/frontend/src/store/api/reducer.ts +++ b/frontend/src/store/api/reducer.ts @@ -57,7 +57,7 @@ interface TwitchBotConfig { type AccessLevelType = 'everyone' | 'vip' | 'moderators' | 'streamer'; -interface TwitchBotCustomCommand { +export interface TwitchBotCustomCommand { description: string; access_level: AccessLevelType; response: string; @@ -353,13 +353,7 @@ const moduleChangeReducers = Object.fromEntries( ]), ) as Record< `${keyof typeof modules}Changed`, - ( - state: unknown, - action: { - payload: unknown; - type: string; - }, - ) => never + (state: APIState, action: PayloadAction) => never >; const apiReducer = createSlice({ diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 1b21ac1..2fe9a18 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -39,11 +39,11 @@ const menu: RouteItem[] = [ route: '/twitch/', subroutes: [ { - name: 'Module Configuration', + name: 'Module configuration', route: '/twitch/settings', }, { - name: 'Bot Configuration', + name: 'Bot configuration', route: '/twitch/bot/settings', }, { diff --git a/frontend/src/ui/pages/twitch/Commands.tsx b/frontend/src/ui/pages/twitch/Commands.tsx index 4a6ec3b..8b56289 100644 --- a/frontend/src/ui/pages/twitch/Commands.tsx +++ b/frontend/src/ui/pages/twitch/Commands.tsx @@ -1,8 +1,316 @@ import { RouteComponentProps } from '@reach/router'; -import React from 'react'; +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useModule } from '../../../lib/react-utils'; +import { modules, TwitchBotCustomCommand } from '../../../store/api/reducer'; +import Modal from '../../components/Modal'; + +interface CommandItemProps { + name: string; + item: TwitchBotCustomCommand; + onToggleState: () => void; + onEdit: () => void; + onDelete: () => void; +} +function CommandItem({ + name, + item, + onToggleState, + onEdit, + onDelete, +}: CommandItemProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+
+ {item.enabled ? ( + {name} + ) : ( + + {name} + + )}{' '} + {item.description} +
+ setExpanded(!expanded)} + > + + ❯ + + +
+ {expanded ? ( +
+ Response:
{item.response}
+
+ + {item.enabled ? 'Disable' : 'Enable'} + {' '} + + Edit + {' '} + + Delete + +
+
+ ) : null} +
+ ); +} + +interface CommandModalProps { + active: boolean; + onConfirm: (newName: string, r: TwitchBotCustomCommand) => void; + onClose: () => void; + initialData?: TwitchBotCustomCommand; + initialName?: string; + title: string; + confirmText: string; +} + +function CommandModal({ + active, + onConfirm, + onClose, + initialName, + initialData, + title, + confirmText, +}: CommandModalProps) { + const [name, setName] = useState(initialName ?? ''); + const [description, setDescription] = useState( + initialData?.description ?? '', + ); + const [response, setResponse] = useState(initialData?.response ?? ''); + + const slugify = (str: string) => + str.toLowerCase().replace(/[^a-zA-Z0-9!.-_@:;'"<>]/gi, '-'); + const validForm = name !== '' && response !== ''; + + const confirm = () => { + if (onConfirm) { + onConfirm(name, { + description, + response, + enabled: initialData?.enabled ?? false, + access_level: 'everyone', + }); + } + }; + + return ( + confirm()} + onClose={() => onClose()} + > +
+
+ +
+
+
+

+ setName(slugify(ev.target.value))} + /> +

+
+
+
+
+
+ +
+
+
+

+ +

+
+
+
+
+
+ +
+
+
+

+ +

+
+
+
+
+
+ +
+
+
+

+ + + +

+

+ This specifies the minimum level, eg. if you choose VIPs, + moderators and streamer can still use the command +

+
+
+
+
+ ); +} export default function TwitchBotCommandsPage( props: RouteComponentProps, ): React.ReactElement { - return <>WIP!!; + const [commands, setCommands] = useModule(modules.twitchBotCommands); + const dispatch = useDispatch(); + + const [createModal, setCreateModal] = useState(false); + const [showModifyCommand, setShowModifyCommand] = useState(null); + const [commandFilter, setCommandFilter] = useState(''); + const commandFilterLC = commandFilter.toLowerCase(); + + const createCommand = (cmd: string, data: TwitchBotCustomCommand): void => { + dispatch( + setCommands({ + ...commands, + [cmd]: data, + }), + ); + setCreateModal(false); + }; + + const modifyCommand = ( + oldName: string, + newName: string, + data: TwitchBotCustomCommand, + ): void => { + dispatch( + setCommands({ + ...commands, + [newName]: { + ...commands[oldName], + ...data, + }, + [oldName]: undefined, + }), + ); + setShowModifyCommand(null); + }; + + const deleteCommand = (cmd: string): void => { + dispatch( + setCommands({ + ...commands, + [cmd]: undefined, + }), + ); + }; + + const toggleCommand = (cmd: string): void => { + dispatch( + setCommands({ + ...commands, + [cmd]: { + ...commands[cmd], + enabled: !commands[cmd].enabled, + }, + }), + ); + }; + + return ( + <> +

Bot commands

+
+

+ +

+ +

+ setCommandFilter(ev.target.value)} + /> +

+
+ + setCreateModal(false)} + /> + {showModifyCommand ? ( + + modifyCommand(showModifyCommand, newName, cmdData) + } + initialName={showModifyCommand} + initialData={showModifyCommand ? commands[showModifyCommand] : null} + onClose={() => setShowModifyCommand(null)} + /> + ) : null} +
+ {Object.keys(commands ?? {}) + ?.filter((cmd) => cmd.toLowerCase().includes(commandFilterLC)) + .map((cmd) => ( + deleteCommand(cmd)} + onEdit={() => setShowModifyCommand(cmd)} + onToggleState={() => toggleCommand(cmd)} + /> + ))} +
+ + ); } diff --git a/modules/twitch/bot.go b/modules/twitch/bot.go index da80d29..ac8c488 100644 --- a/modules/twitch/bot.go +++ b/modules/twitch/bot.go @@ -1,12 +1,15 @@ package twitch import ( + "context" "strings" "sync" "time" irc "github.com/gempir/go-twitch-irc/v2" + jsoniter "github.com/json-iterator/go" "github.com/sirupsen/logrus" + "github.com/strimertul/strimertul/database" "github.com/strimertul/strimertul/modules/loyalty" ) @@ -22,6 +25,9 @@ type Bot struct { banlist map[string]bool chatHistory []irc.PrivateMessage + commands map[string]BotCommand + customCommands map[string]BotCustomCommand + mu sync.Mutex // Module specific vars @@ -33,15 +39,17 @@ func NewBot(api *Client, config BotConfig) *Bot { client := irc.NewClient(config.Username, config.Token) bot := &Bot{ - Client: client, - username: strings.ToLower(config.Username), // Normalize username - config: config, - logger: api.logger, - api: api, - lastMessage: time.Now(), - activeUsers: make(map[string]bool), - banlist: make(map[string]bool), - mu: sync.Mutex{}, + Client: client, + username: strings.ToLower(config.Username), // Normalize username + config: config, + logger: api.logger, + api: api, + lastMessage: time.Now(), + activeUsers: make(map[string]bool), + banlist: make(map[string]bool), + mu: sync.Mutex{}, + commands: make(map[string]BotCommand), + customCommands: make(map[string]BotCustomCommand), } client.OnPrivateMessage(func(message irc.PrivateMessage) { @@ -52,12 +60,11 @@ func NewBot(api *Client, config BotConfig) *Bot { } bot.mu.Lock() bot.activeUsers[message.User.Name] = true - bot.mu.Unlock() // Check if it's a command if strings.HasPrefix(message.Message, "!") { // Run through supported commands - for cmd, data := range commands { + for cmd, data := range bot.commands { if !data.Enabled { continue } @@ -69,7 +76,7 @@ func NewBot(api *Client, config BotConfig) *Bot { } // Run through custom commands - for cmd, data := range customCommands { + for cmd, data := range bot.customCommands { if !data.Enabled { continue } @@ -78,6 +85,7 @@ func NewBot(api *Client, config BotConfig) *Bot { bot.lastMessage = time.Now() } } + bot.mu.Unlock() if bot.config.EnableChatKeys { bot.api.db.PutJSON(ChatEventKey, message) @@ -115,9 +123,27 @@ func NewBot(api *Client, config BotConfig) *Bot { bot.Client.Join(config.Channel) + // Load custom commands + api.db.GetJSON(CustomCommandsKey, &bot.customCommands) + go api.db.Subscribe(context.Background(), bot.updateCommands, CustomCommandsKey) + return bot } +func (b *Bot) updateCommands(kvs []database.ModifiedKV) error { + for _, kv := range kvs { + key := string(kv.Key) + switch key { + case CustomCommandsKey: + b.mu.Lock() + err := jsoniter.ConfigFastest.Unmarshal(kv.Data, &b.customCommands) + b.mu.Unlock() + return err + } + } + return nil +} + func (b *Bot) Connect() error { return b.Client.Connect() } diff --git a/modules/twitch/commands.go b/modules/twitch/commands.go index 3a156f8..166d083 100644 --- a/modules/twitch/commands.go +++ b/modules/twitch/commands.go @@ -23,9 +23,6 @@ type BotCommand struct { Enabled bool } -var commands = map[string]BotCommand{} -var customCommands = map[string]BotCustomCommand{} - func cmdCustom(bot *Bot, cmd BotCustomCommand, message irc.PrivateMessage) { // Add future logic (like counters etc) here, for now it's just fixed messages bot.Client.Say(message.Channel, cmd.Response) diff --git a/modules/twitch/loyalty.go b/modules/twitch/loyalty.go index 4f22099..7ba06cd 100644 --- a/modules/twitch/loyalty.go +++ b/modules/twitch/loyalty.go @@ -17,25 +17,25 @@ func (b *Bot) SetupLoyalty(loyalty *loyalty.Manager) { b.SetBanList(config.BanList) // Add loyalty-based commands - commands["!redeem"] = BotCommand{ + b.commands["!redeem"] = BotCommand{ Description: "Redeem a reward with loyalty points", Usage: "!redeem [request text]", AccessLevel: ALTEveryone, Handler: cmdRedeemReward, } - commands["!balance"] = BotCommand{ + b.commands["!balance"] = BotCommand{ Description: "See your current point balance", Usage: "!balance", AccessLevel: ALTEveryone, Handler: cmdBalance, } - commands["!goals"] = BotCommand{ + b.commands["!goals"] = BotCommand{ Description: "Check currently active community goals", Usage: "!goals", AccessLevel: ALTEveryone, Handler: cmdGoalList, } - commands["!contribute"] = BotCommand{ + b.commands["!contribute"] = BotCommand{ Description: "Contribute points to a community goal", Usage: "!contribute []", AccessLevel: ALTEveryone,