1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-18 01:50:50 +00:00

Custom commands are now working!

This commit is contained in:
Ash Keel 2021-09-17 10:54:55 +02:00
parent 3cb74e820d
commit f2ebc7df6f
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
7 changed files with 366 additions and 31 deletions

View file

@ -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;

View file

@ -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({

View file

@ -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',
},
{

View file

@ -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>
</>
);
}

View file

@ -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()
}

View file

@ -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)

View file

@ -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,