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;
|
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! */
|
/* Nice expand/contract icon without FontAwesome! */
|
||||||
.icon.expand-on, .icon.expand-off {
|
.icon.expand-on, .icon.expand-off {
|
||||||
transition: all 50ms;
|
transition: all 50ms;
|
||||||
|
|
|
@ -57,7 +57,7 @@ interface TwitchBotConfig {
|
||||||
|
|
||||||
type AccessLevelType = 'everyone' | 'vip' | 'moderators' | 'streamer';
|
type AccessLevelType = 'everyone' | 'vip' | 'moderators' | 'streamer';
|
||||||
|
|
||||||
interface TwitchBotCustomCommand {
|
export interface TwitchBotCustomCommand {
|
||||||
description: string;
|
description: string;
|
||||||
access_level: AccessLevelType;
|
access_level: AccessLevelType;
|
||||||
response: string;
|
response: string;
|
||||||
|
@ -353,13 +353,7 @@ const moduleChangeReducers = Object.fromEntries(
|
||||||
]),
|
]),
|
||||||
) as Record<
|
) as Record<
|
||||||
`${keyof typeof modules}Changed`,
|
`${keyof typeof modules}Changed`,
|
||||||
(
|
(state: APIState, action: PayloadAction<unknown>) => never
|
||||||
state: unknown,
|
|
||||||
action: {
|
|
||||||
payload: unknown;
|
|
||||||
type: string;
|
|
||||||
},
|
|
||||||
) => never
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const apiReducer = createSlice({
|
const apiReducer = createSlice({
|
||||||
|
|
|
@ -39,11 +39,11 @@ const menu: RouteItem[] = [
|
||||||
route: '/twitch/',
|
route: '/twitch/',
|
||||||
subroutes: [
|
subroutes: [
|
||||||
{
|
{
|
||||||
name: 'Module Configuration',
|
name: 'Module configuration',
|
||||||
route: '/twitch/settings',
|
route: '/twitch/settings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Bot Configuration',
|
name: 'Bot configuration',
|
||||||
route: '/twitch/bot/settings',
|
route: '/twitch/bot/settings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,8 +1,316 @@
|
||||||
import { RouteComponentProps } from '@reach/router';
|
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(
|
export default function TwitchBotCommandsPage(
|
||||||
props: RouteComponentProps<unknown>,
|
props: RouteComponentProps<unknown>,
|
||||||
): React.ReactElement {
|
): 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
|
package twitch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
irc "github.com/gempir/go-twitch-irc/v2"
|
irc "github.com/gempir/go-twitch-irc/v2"
|
||||||
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/strimertul/strimertul/database"
|
||||||
"github.com/strimertul/strimertul/modules/loyalty"
|
"github.com/strimertul/strimertul/modules/loyalty"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -22,6 +25,9 @@ type Bot struct {
|
||||||
banlist map[string]bool
|
banlist map[string]bool
|
||||||
chatHistory []irc.PrivateMessage
|
chatHistory []irc.PrivateMessage
|
||||||
|
|
||||||
|
commands map[string]BotCommand
|
||||||
|
customCommands map[string]BotCustomCommand
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
// Module specific vars
|
// Module specific vars
|
||||||
|
@ -33,15 +39,17 @@ func NewBot(api *Client, config BotConfig) *Bot {
|
||||||
client := irc.NewClient(config.Username, config.Token)
|
client := irc.NewClient(config.Username, config.Token)
|
||||||
|
|
||||||
bot := &Bot{
|
bot := &Bot{
|
||||||
Client: client,
|
Client: client,
|
||||||
username: strings.ToLower(config.Username), // Normalize username
|
username: strings.ToLower(config.Username), // Normalize username
|
||||||
config: config,
|
config: config,
|
||||||
logger: api.logger,
|
logger: api.logger,
|
||||||
api: api,
|
api: api,
|
||||||
lastMessage: time.Now(),
|
lastMessage: time.Now(),
|
||||||
activeUsers: make(map[string]bool),
|
activeUsers: make(map[string]bool),
|
||||||
banlist: make(map[string]bool),
|
banlist: make(map[string]bool),
|
||||||
mu: sync.Mutex{},
|
mu: sync.Mutex{},
|
||||||
|
commands: make(map[string]BotCommand),
|
||||||
|
customCommands: make(map[string]BotCustomCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
client.OnPrivateMessage(func(message irc.PrivateMessage) {
|
client.OnPrivateMessage(func(message irc.PrivateMessage) {
|
||||||
|
@ -52,12 +60,11 @@ func NewBot(api *Client, config BotConfig) *Bot {
|
||||||
}
|
}
|
||||||
bot.mu.Lock()
|
bot.mu.Lock()
|
||||||
bot.activeUsers[message.User.Name] = true
|
bot.activeUsers[message.User.Name] = true
|
||||||
bot.mu.Unlock()
|
|
||||||
|
|
||||||
// Check if it's a command
|
// Check if it's a command
|
||||||
if strings.HasPrefix(message.Message, "!") {
|
if strings.HasPrefix(message.Message, "!") {
|
||||||
// Run through supported commands
|
// Run through supported commands
|
||||||
for cmd, data := range commands {
|
for cmd, data := range bot.commands {
|
||||||
if !data.Enabled {
|
if !data.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -69,7 +76,7 @@ func NewBot(api *Client, config BotConfig) *Bot {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run through custom commands
|
// Run through custom commands
|
||||||
for cmd, data := range customCommands {
|
for cmd, data := range bot.customCommands {
|
||||||
if !data.Enabled {
|
if !data.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -78,6 +85,7 @@ func NewBot(api *Client, config BotConfig) *Bot {
|
||||||
bot.lastMessage = time.Now()
|
bot.lastMessage = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bot.mu.Unlock()
|
||||||
|
|
||||||
if bot.config.EnableChatKeys {
|
if bot.config.EnableChatKeys {
|
||||||
bot.api.db.PutJSON(ChatEventKey, message)
|
bot.api.db.PutJSON(ChatEventKey, message)
|
||||||
|
@ -115,9 +123,27 @@ func NewBot(api *Client, config BotConfig) *Bot {
|
||||||
|
|
||||||
bot.Client.Join(config.Channel)
|
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
|
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 {
|
func (b *Bot) Connect() error {
|
||||||
return b.Client.Connect()
|
return b.Client.Connect()
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,9 +23,6 @@ type BotCommand struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var commands = map[string]BotCommand{}
|
|
||||||
var customCommands = map[string]BotCustomCommand{}
|
|
||||||
|
|
||||||
func cmdCustom(bot *Bot, cmd BotCustomCommand, message irc.PrivateMessage) {
|
func cmdCustom(bot *Bot, cmd BotCustomCommand, message irc.PrivateMessage) {
|
||||||
// Add future logic (like counters etc) here, for now it's just fixed messages
|
// Add future logic (like counters etc) here, for now it's just fixed messages
|
||||||
bot.Client.Say(message.Channel, cmd.Response)
|
bot.Client.Say(message.Channel, cmd.Response)
|
||||||
|
|
|
@ -17,25 +17,25 @@ func (b *Bot) SetupLoyalty(loyalty *loyalty.Manager) {
|
||||||
b.SetBanList(config.BanList)
|
b.SetBanList(config.BanList)
|
||||||
|
|
||||||
// Add loyalty-based commands
|
// Add loyalty-based commands
|
||||||
commands["!redeem"] = BotCommand{
|
b.commands["!redeem"] = BotCommand{
|
||||||
Description: "Redeem a reward with loyalty points",
|
Description: "Redeem a reward with loyalty points",
|
||||||
Usage: "!redeem <reward-id> [request text]",
|
Usage: "!redeem <reward-id> [request text]",
|
||||||
AccessLevel: ALTEveryone,
|
AccessLevel: ALTEveryone,
|
||||||
Handler: cmdRedeemReward,
|
Handler: cmdRedeemReward,
|
||||||
}
|
}
|
||||||
commands["!balance"] = BotCommand{
|
b.commands["!balance"] = BotCommand{
|
||||||
Description: "See your current point balance",
|
Description: "See your current point balance",
|
||||||
Usage: "!balance",
|
Usage: "!balance",
|
||||||
AccessLevel: ALTEveryone,
|
AccessLevel: ALTEveryone,
|
||||||
Handler: cmdBalance,
|
Handler: cmdBalance,
|
||||||
}
|
}
|
||||||
commands["!goals"] = BotCommand{
|
b.commands["!goals"] = BotCommand{
|
||||||
Description: "Check currently active community goals",
|
Description: "Check currently active community goals",
|
||||||
Usage: "!goals",
|
Usage: "!goals",
|
||||||
AccessLevel: ALTEveryone,
|
AccessLevel: ALTEveryone,
|
||||||
Handler: cmdGoalList,
|
Handler: cmdGoalList,
|
||||||
}
|
}
|
||||||
commands["!contribute"] = BotCommand{
|
b.commands["!contribute"] = BotCommand{
|
||||||
Description: "Contribute points to a community goal",
|
Description: "Contribute points to a community goal",
|
||||||
Usage: "!contribute <points> [<goal-id>]",
|
Usage: "!contribute <points> [<goal-id>]",
|
||||||
AccessLevel: ALTEveryone,
|
AccessLevel: ALTEveryone,
|
||||||
|
|
Loading…
Reference in a new issue