mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-20 02:00:49 +00:00
Custom commands are now working!
This commit is contained in:
parent
3cb74e820d
commit
f2ebc7df6f
7 changed files with 366 additions and 31 deletions
|
@ -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;
|
||||
|
|
|
@ -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<unknown>) => never
|
||||
>;
|
||||
|
||||
const apiReducer = createSlice({
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 (
|
||||
<div className="card customcommand" style={{ marginBottom: '3px' }}>
|
||||
<header className="card-header">
|
||||
<div className="card-header-title">
|
||||
{item.enabled ? (
|
||||
<code>{name}</code>
|
||||
) : (
|
||||
<span className="reward-disabled">
|
||||
<code>{name}</code>
|
||||
</span>
|
||||
)}{' '}
|
||||
{item.description}
|
||||
</div>
|
||||
<a
|
||||
className="card-header-icon"
|
||||
aria-label="expand"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className={expanded ? 'icon expand-off' : 'icon expand-on'}>
|
||||
❯
|
||||
</span>
|
||||
</a>
|
||||
</header>
|
||||
{expanded ? (
|
||||
<div className="content">
|
||||
Response: <blockquote>{item.response}</blockquote>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<a className="button is-small" onClick={onToggleState}>
|
||||
{item.enabled ? 'Disable' : 'Enable'}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onEdit}>
|
||||
Edit
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onDelete}>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
active={active}
|
||||
title={title}
|
||||
showCancel={true}
|
||||
bgDismiss={true}
|
||||
confirmName={confirmText}
|
||||
confirmClass="is-success"
|
||||
confirmEnabled={validForm}
|
||||
onConfirm={() => confirm()}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Command</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className={name !== '' ? 'input' : 'input is-danger'}
|
||||
type="text"
|
||||
placeholder="!mycommand"
|
||||
value={name}
|
||||
onChange={(ev) => setName(slugify(ev.target.value))}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Description</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="What does this command do?"
|
||||
rows={1}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
value={description}
|
||||
></textarea>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Response</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<textarea
|
||||
className={response !== '' ? 'textarea' : 'textarea is-danger'}
|
||||
placeholder="What does the bot reply to this command?"
|
||||
onChange={(ev) => setResponse(ev.target.value)}
|
||||
value={response}
|
||||
></textarea>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Access level</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<span className="select">
|
||||
<select>
|
||||
<option value="everyone">Everyone</option>
|
||||
<option value="vip">VIPs</option>
|
||||
<option value="moderators">Moderators</option>
|
||||
<option value="streamer">Streamer only</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p className="help">
|
||||
This specifies the minimum level, eg. if you choose VIPs,
|
||||
moderators and streamer can still use the command
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TwitchBotCommandsPage(
|
||||
props: RouteComponentProps<unknown>,
|
||||
): 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 (
|
||||
<>
|
||||
<h1 className="title is-4">Bot commands</h1>
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<button className="button" onClick={() => setCreateModal(true)}>
|
||||
New command
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Search by name"
|
||||
value={commandFilter}
|
||||
onChange={(ev) => setCommandFilter(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommandModal
|
||||
title="New command"
|
||||
confirmText="Create"
|
||||
active={createModal}
|
||||
onConfirm={createCommand}
|
||||
onClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{showModifyCommand ? (
|
||||
<CommandModal
|
||||
title="Modify command"
|
||||
confirmText="Edit"
|
||||
active={true}
|
||||
onConfirm={(newName, cmdData) =>
|
||||
modifyCommand(showModifyCommand, newName, cmdData)
|
||||
}
|
||||
initialName={showModifyCommand}
|
||||
initialData={showModifyCommand ? commands[showModifyCommand] : null}
|
||||
onClose={() => setShowModifyCommand(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="reward-list" style={{ marginTop: '1rem' }}>
|
||||
{Object.keys(commands ?? {})
|
||||
?.filter((cmd) => cmd.toLowerCase().includes(commandFilterLC))
|
||||
.map((cmd) => (
|
||||
<CommandItem
|
||||
key={cmd}
|
||||
name={cmd}
|
||||
item={commands[cmd]}
|
||||
onDelete={() => deleteCommand(cmd)}
|
||||
onEdit={() => setShowModifyCommand(cmd)}
|
||||
onToggleState={() => toggleCommand(cmd)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
@ -42,6 +48,8 @@ func NewBot(api *Client, config BotConfig) *Bot {
|
|||
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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 <reward-id> [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 <points> [<goal-id>]",
|
||||
AccessLevel: ALTEveryone,
|
||||
|
|
Loading…
Reference in a new issue