feat: migration and more chat stuff

This commit is contained in:
Ash Keel 2024-03-12 23:39:18 +01:00
parent 0d1c60451b
commit 31d44b950e
No known key found for this signature in database
GPG Key ID: 53A9E9A6035DD109
27 changed files with 368 additions and 310 deletions

View File

@ -23,7 +23,7 @@ You can also build the project yourself, refer to the Building section below.
Strimertül is a single executable app that provides the following:
- HTTP server for serving static assets and a websocket API
- Twitch bot for handling chat messages and providing custom commands
- Twitch EventSub handlers for chat functionalities such as custom commands and alerts
- Polling-based loyalty system for rewards and community goals
At strimertül's core is [Kilovolt](https://git.sr.ht/~ashkeel/kilovolt), a pub/sub key-value store accessible via websocket. You can access every functionality of strimertul through the Kilovolt API. Check [this repository](https://github.com/strimertul/kilovolt-clients) for a list of officially supported kilovolt clients (or submit your own). You should be able to easily build a client yourself by just creating a websocket connection and using the [Kilovolt protocol](https://github.com/strimertul/kilovolt/blob/main/PROTOCOL.md).
@ -54,6 +54,4 @@ To build a redistributable, production mode package, use `wails build`.
## License
Kilovolt's code is based on Gorilla Websocket's server example, licensed under [BSD-2-Clause](https://github.com/gorilla/websocket/blob/master/LICENSE)
The entire project is licensed under [AGPL-3.0-only](LICENSE) (see `LICENSE`).

8
app.go
View File

@ -12,6 +12,8 @@ import (
"runtime/debug"
"strconv"
"git.sr.ht/~ashkeel/strimertul/migrations"
"git.sr.ht/~ashkeel/strimertul/twitch/client"
"github.com/wailsapp/wails/v2/pkg/options"
@ -88,6 +90,12 @@ func (a *App) startup(ctx context.Context) {
return
}
// Check for migrations
if err := migrations.Run(a.driver, a.db, logger); err != nil {
a.showFatalError(err, "Failed to migrate database to latest version")
return
}
// Initialize components
if err := a.initializeComponents(); err != nil {
a.showFatalError(err, "Failed to initialize required component")

View File

@ -24,9 +24,9 @@
},
"twitch": {
"configuration": "Configuration",
"bot-commands": "Chat commands",
"bot-timers": "Chat timers",
"bot-alerts": "Chat alerts"
"chat-commands": "Chat commands",
"chat-timers": "Chat timers",
"chat-alerts": "Chat alerts"
},
"loyalty": {
"configuration": "Configuration",
@ -66,18 +66,16 @@
"apiguide-4": "Once made, create a <1>New Secret</1>, then copy both fields below and save!",
"app-client-id": "App Client ID",
"app-client-secret": "App Client Secret",
"subtitle": "Twitch integration with streams including chat bot and API access. If you stream on Twitch, you definitely want this on.",
"subtitle": "Twitch integration with streams including chat interactions and API access. If you stream on Twitch, you definitely want this on.",
"api-subheader": "Application info",
"api-configuration": "API access",
"eventsub": "Events",
"bot-settings": "Bot settings",
"enable-bot": "Enable Twitch bot",
"chat-settings": "Chat settings",
"enable-bot": "Enable chat features",
"bot-channel": "Twitch channel",
"bot-username": "Twitch account username",
"bot-oauth": "Authorization token",
"bot-oauth-note": "You can get this by logging in with the bot account and going here: <1>https://twitchapps.com/tmi/</1>",
"bot-info-header": "Bot account info",
"bot-settings-copy": "A bot can interact with chat messages and provide extra events for the platform (chat events, some notifications) but requires access to a Twitch account. You can use your own or make a new one (if enabled on your main account, you can re-use the same email for your second account!)",
"bot-chat-header": "Chat settings",
"bot-chat-history": "How many messages to keep in history (0 to disable)",
"events": {
@ -107,7 +105,7 @@
"bot-chat-cooldown-tip": "Global chat cooldown for commands (in seconds)"
},
"botcommands": {
"title": "Bot commands",
"title": "Chat commands",
"desc": "Define custom chat commands to set up autoresponders, counters, etc.",
"add-button": "New command",
"search-placeholder": "Search command by name",
@ -135,12 +133,12 @@
"streamer": "Streamer only"
},
"remove-command-title": "Remove command {{name}}?",
"no-commands": "Chatbot has no commands configured",
"no-commands": "There are no commands configured",
"command-already-in-use": "Command name already in use",
"command-invalid-format": "The response template contains errors"
},
"bottimers": {
"title": "Bot timers",
"title": "Chat timers",
"desc": "Define reminders such as checking out your social media or ongoing events",
"add-button": "New timer",
"search-placeholder": "Search timer by name",
@ -301,7 +299,7 @@
"landing": "Welcome",
"twitch-config": "Twitch integration",
"twitch-events": "Twitch events",
"twitch-bot": "Twitch bot",
"twitch-bot": "Twitch chat",
"done": "All done!"
},
"twitch-p1": "To set-up Twitch you will need to create an application on the Developer portal. Follow the instructions below or click the button at the bottom to skip this step.",
@ -311,7 +309,7 @@
"twitch-ev-p3": "If the above shows your account name and picture correctly, you're all set! Click the button below to complete Twitch integration.",
"twitch-complete": "Complete Twitch integration",
"done-header": "You're all set!",
"done-p1": "That should be enough for now. You can always change any option later, including custom configurations not covered in this procedure (e.g. using a different Twitch account for the bot).",
"done-p1": "That should be enough for now. You can always change any option later, including custom configurations not covered in this procedure (e.g. using a different Twitch account for the chat integrations).",
"done-p2": "If you have questions or issues, please reach out at any of these places:",
"done-button": "Complete onboarding",
"done-p3": "Click the button below to finish the onboarding and go to {{APPNAME}}'s dashboard.",

View File

@ -74,9 +74,9 @@
"extensions": "Estensioni"
},
"twitch": {
"bot-alerts": "Avvisi in chat",
"bot-commands": "Comandi bot",
"bot-timers": "Timer bot",
"chat-alerts": "Avvisi in chat",
"chat-commands": "Comandi chat",
"chat-timers": "Timer chat",
"configuration": "Configurazione"
}
},
@ -332,10 +332,9 @@
"bot-channel": "Canale Twitch",
"bot-chat-header": "Impostazioni chat",
"bot-chat-history": "Quanti messaggi tenere nello storico (0 per disabilitare)",
"bot-info-header": "Informazioni account del bot",
"bot-oauth": "Token di autorizzazione",
"bot-oauth-note": "Puoi ottenerlo accedendo con l'account del bot e andando qui: <1>https://twitchapps.com/tmi/</1>",
"bot-settings": "Impostazioni bot",
"chat-settings": "Impostazioni chat",
"bot-settings-copy": "Un bot può interagire con i messaggi in chat e scriverci per avvertimenti ed altre funzionalità ma richiede l'accesso ad un account Twitch. \nPuoi usare il tuo account o crearne uno apposta (se abilitato sul tuo account principale, puoi riutilizzare la stessa email per un secondo account!)",
"bot-username": "Nome utente dell'account Twitch",
"enable": "Abilita integrazione Twitch",

View File

@ -20,11 +20,11 @@ import {
LoyaltyPointsEntry,
LoyaltyRedeem,
LoyaltyStorage,
TwitchBotConfig,
TwitchChatConfig,
TwitchConfig,
TwitchBotCustomCommands,
TwitchBotTimersConfig,
TwitchBotAlertsConfig,
TwitchChatCustomCommands,
TwitchChatTimersConfig,
TwitchChatAlertsConfig,
LoyaltyConfig,
LoyaltyReward,
LoyaltyGoal,
@ -165,32 +165,32 @@ export const modules = {
state.moduleConfigs.twitchConfig = payload as TwitchConfig;
},
),
twitchBotConfig: makeModule(
'twitch/bot-config',
(state) => state.moduleConfigs?.twitchBotConfig,
twitchChatConfig: makeModule(
'twitch/chat/config',
(state) => state.moduleConfigs?.twitchChatConfig,
(state, { payload }) => {
state.moduleConfigs.twitchBotConfig = payload as TwitchBotConfig;
state.moduleConfigs.twitchChatConfig = payload as TwitchChatConfig;
},
),
twitchBotCommands: makeModule(
'twitch/bot-custom-commands',
(state) => state.twitchBot?.commands,
twitchChatCommands: makeModule(
'twitch/chat/custom-commands',
(state) => state.twitchChat?.commands,
(state, { payload }) => {
state.twitchBot.commands = payload as TwitchBotCustomCommands;
state.twitchChat.commands = payload as TwitchChatCustomCommands;
},
),
twitchBotTimers: makeModule(
'twitch/bot-modules/timers/config',
(state) => state.twitchBot?.timers,
twitchChatTimers: makeModule(
'twitch/timers/config',
(state) => state.twitchChat?.timers,
(state, { payload }) => {
state.twitchBot.timers = payload as TwitchBotTimersConfig;
state.twitchChat.timers = payload as TwitchChatTimersConfig;
},
),
twitchBotAlerts: makeModule(
'twitch/bot-modules/alerts/config',
(state) => state.twitchBot?.alerts,
twitchChatAlerts: makeModule(
'twitch/alerts/config',
(state) => state.twitchChat?.alerts,
(state, { payload }) => {
state.twitchBot.alerts = payload as TwitchBotAlertsConfig;
state.twitchChat.alerts = payload as TwitchChatAlertsConfig;
},
),
loyaltyConfig: makeModule(
@ -269,7 +269,7 @@ const initialState: APIState = {
goals: null,
redeemQueue: null,
},
twitchBot: {
twitchChat: {
commands: null,
timers: null,
alerts: null,
@ -277,7 +277,7 @@ const initialState: APIState = {
moduleConfigs: {
httpConfig: null,
twitchConfig: null,
twitchBotConfig: null,
twitchChatConfig: null,
loyaltyConfig: null,
},
uiConfig: null,

View File

@ -12,16 +12,11 @@ export interface HTTPConfig {
export interface TwitchConfig {
enabled: boolean;
enable_bot: boolean;
api_client_id: string;
api_client_secret: string;
}
export interface TwitchBotConfig {
username: string;
oauth: string;
channel: string;
chat_history: number;
export interface TwitchChatConfig {
command_cooldown: number;
}
@ -36,7 +31,7 @@ export const accessLevels = [
export type AccessLevelType = (typeof accessLevels)[number];
export type ReplyType = 'chat' | 'reply' | 'whisper' | 'announce';
export interface TwitchBotCustomCommand {
export interface TwitchChatCustomCommand {
description: string;
access_level: AccessLevelType;
response: string;
@ -44,7 +39,7 @@ export interface TwitchBotCustomCommand {
enabled: boolean;
}
export type TwitchBotCustomCommands = Record<string, TwitchBotCustomCommand>;
export type TwitchChatCustomCommands = Record<string, TwitchChatCustomCommand>;
export interface LoyaltyConfig {
enabled: boolean;
@ -57,7 +52,7 @@ export interface LoyaltyConfig {
banlist: string[];
}
export interface TwitchBotTimer {
export interface TwitchChatTimer {
enabled: boolean;
name: string;
minimum_chat_activity: number;
@ -65,11 +60,11 @@ export interface TwitchBotTimer {
messages: string[];
}
export interface TwitchBotTimersConfig {
timers: Record<string, TwitchBotTimer>;
export interface TwitchChatTimersConfig {
timers: Record<string, TwitchChatTimer>;
}
export interface TwitchBotAlertsConfig {
export interface TwitchChatAlertsConfig {
follow: {
enabled: boolean;
messages: string[];
@ -176,15 +171,15 @@ export interface APIState {
goals: LoyaltyGoal[];
redeemQueue: LoyaltyRedeem[];
};
twitchBot: {
commands: TwitchBotCustomCommands;
timers: TwitchBotTimersConfig;
alerts: TwitchBotAlertsConfig;
twitchChat: {
commands: TwitchChatCustomCommands;
timers: TwitchChatTimersConfig;
alerts: TwitchChatAlertsConfig;
};
moduleConfigs: {
httpConfig: HTTPConfig;
twitchConfig: TwitchConfig;
twitchBotConfig: TwitchBotConfig;
twitchChatConfig: TwitchChatConfig;
loyaltyConfig: LoyaltyConfig;
};
uiConfig: UISettings;

View File

@ -31,8 +31,8 @@ import { initializeServerInfo } from '~/store/server/reducer';
import LogViewer from './components/LogViewer';
import Sidebar, { RouteSection } from './components/Sidebar';
import Scrollbar from './components/utils/Scrollbar';
import TwitchBotCommandsPage from './pages/BotCommands';
import TwitchBotTimersPage from './pages/BotTimers';
import TwitchChatCommandsPage from './pages/ChatCommands';
import TwitchChatTimersPage from './pages/ChatTimers';
import ChatAlertsPage from './pages/ChatAlerts';
import Dashboard from './pages/Dashboard';
import DebugPage from './pages/Debug';
@ -92,18 +92,18 @@ const sections: RouteSection[] = [
icon: <MixerHorizontalIcon />,
},
{
title: 'menu.pages.twitch.bot-commands',
url: '/twitch/bot/commands',
title: 'menu.pages.twitch.chat-commands',
url: '/twitch/chat/commands',
icon: <ChatBubbleIcon />,
},
{
title: 'menu.pages.twitch.bot-timers',
url: '/twitch/bot/timers',
title: 'menu.pages.twitch.chat-timers',
url: '/twitch/chat/timers',
icon: <TimerIcon />,
},
{
title: 'menu.pages.twitch.bot-alerts',
url: '/twitch/bot/alerts',
title: 'menu.pages.twitch.chat-alerts',
url: '/twitch/chat/alerts',
icon: <FrameIcon />,
},
],
@ -277,14 +277,14 @@ export default function App(): JSX.Element {
<Route path="/extensions" element={<ExtensionsPage />} />
<Route path="/twitch/settings" element={<TwitchSettingsPage />} />
<Route
path="/twitch/bot/commands"
element={<TwitchBotCommandsPage />}
path="/twitch/chat/commands"
element={<TwitchChatCommandsPage />}
/>
<Route
path="/twitch/bot/timers"
element={<TwitchBotTimersPage />}
path="/twitch/chat/timers"
element={<TwitchChatTimersPage />}
/>
<Route path="/twitch/bot/alerts" element={<ChatAlertsPage />} />
<Route path="/twitch/chat/alerts" element={<ChatAlertsPage />} />
<Route path="/loyalty/settings" element={<LoyaltyConfigPage />} />
<Route path="/loyalty/users" element={<LoyaltyQueuePage />} />
<Route path="/loyalty/rewards" element={<LoyaltyRewardsPage />} />

View File

@ -25,7 +25,7 @@ import SaveButton from '../components/forms/SaveButton';
export default function ChatAlertsPage(): React.ReactElement {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [alerts, setAlerts, loadStatus] = useModule(modules.twitchBotAlerts);
const [alerts, setAlerts, loadStatus] = useModule(modules.twitchChatAlerts);
const status = useStatus(loadStatus.save);
return (
@ -63,7 +63,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.follow?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
follow: {
...alerts.follow,
@ -93,7 +93,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.follow?.enabled ?? false}
onChange={(messages) => {
dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
follow: { ...alerts.follow, messages },
}),
@ -109,7 +109,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.subscription?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
subscription: {
...alerts.subscription,
@ -139,7 +139,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.subscription?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
subscription: { ...alerts.subscription, messages },
}),
@ -156,7 +156,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.gift_sub?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
gift_sub: {
...alerts.gift_sub,
@ -186,7 +186,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.gift_sub?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
gift_sub: { ...alerts.gift_sub, messages },
}),
@ -203,7 +203,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.raid?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
raid: {
...alerts.raid,
@ -233,7 +233,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.raid?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
raid: { ...alerts.raid, messages },
}),
@ -250,7 +250,7 @@ export default function ChatAlertsPage(): React.ReactElement {
checked={alerts?.cheer?.enabled ?? false}
onCheckedChange={(ev) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
cheer: {
...alerts.cheer,
@ -280,7 +280,7 @@ export default function ChatAlertsPage(): React.ReactElement {
required={alerts?.cheer?.enabled ?? false}
onChange={(messages) => {
void dispatch(
apiReducer.actions.twitchBotAlertsChanged({
apiReducer.actions.twitchChatAlertsChanged({
...alerts,
cheer: { ...alerts.cheer, messages },
}),

View File

@ -8,7 +8,7 @@ import {
accessLevels,
AccessLevelType,
ReplyType,
TwitchBotCustomCommand,
TwitchChatCustomCommand,
} from '~/store/api/types';
import { TestCommandTemplate } from '@wailsapp/go/main/App';
import AlertContent from '../components/AlertContent';
@ -137,7 +137,7 @@ const ACLIndicator = styled('span', {
interface CommandItemProps {
name: string;
item: TwitchBotCustomCommand;
item: TwitchChatCustomCommand;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
@ -212,7 +212,7 @@ function CommandItem({
type DialogPrompt =
| { kind: 'new' }
| { kind: 'edit'; name: string; item: TwitchBotCustomCommand };
| { kind: 'edit'; name: string; item: TwitchChatCustomCommand };
function CommandDialog({
kind,
@ -222,10 +222,10 @@ function CommandDialog({
}: {
kind: 'new' | 'edit';
name?: string;
item?: TwitchBotCustomCommand;
onSubmit?: (name: string, item: TwitchBotCustomCommand) => void;
item?: TwitchChatCustomCommand;
onSubmit?: (name: string, item: TwitchChatCustomCommand) => void;
}) {
const [commands] = useModule(modules.twitchBotCommands);
const [commands] = useModule(modules.twitchChatCommands);
const [commandName, setCommandName] = useState(name ?? '');
const [description, setDescription] = useState(item?.description ?? '');
const [responseType, setResponseType] = useState(
@ -376,8 +376,8 @@ function CommandDialog({
);
}
export default function TwitchBotCommandsPage(): React.ReactElement {
const [commands, setCommands] = useModule(modules.twitchBotCommands);
export default function TwitchChatCommandsPage(): React.ReactElement {
const [commands, setCommands] = useModule(modules.twitchChatCommands);
const [filter, setFilter] = useState('');
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
@ -385,7 +385,7 @@ export default function TwitchBotCommandsPage(): React.ReactElement {
const filterLC = filter.toLowerCase();
const setCommand = (newName: string, data: TwitchBotCustomCommand): void => {
const setCommand = (newName: string, data: TwitchChatCustomCommand): void => {
switch (activeDialog.kind) {
case 'new':
void dispatch(

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { TwitchBotTimer } from '~/store/api/types';
import { TwitchChatTimer } from '~/store/api/types';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import Interval from '../components/forms/Interval';
@ -108,7 +108,7 @@ function humanTime(t: TFunction<'translation'>, secs: number): string {
interface TimerItemProps {
name: string;
item: TwitchBotTimer;
item: TwitchChatTimer;
onToggle?: () => void;
onEdit?: () => void;
onDelete?: () => void;
@ -182,7 +182,7 @@ function TimerItem({
type DialogPrompt =
| { kind: 'new' }
| { kind: 'edit'; name: string; item: TwitchBotTimer };
| { kind: 'edit'; name: string; item: TwitchChatTimer };
function TimerDialog({
kind,
@ -192,10 +192,10 @@ function TimerDialog({
}: {
kind: 'new' | 'edit';
name?: string;
item?: TwitchBotTimer;
onSubmit?: (name: string, item: TwitchBotTimer) => void;
item?: TwitchChatTimer;
onSubmit?: (name: string, item: TwitchChatTimer) => void;
}) {
const [timerConfig] = useModule(modules.twitchBotTimers);
const [timerConfig] = useModule(modules.twitchChatTimers);
const [timerName, setName] = useState(name ?? '');
const [messages, setMessages] = useState(item?.messages ?? ['']);
const [minDelay, setMinDelay] = useState(item?.minimum_delay ?? 300);
@ -308,8 +308,8 @@ function TimerDialog({
);
}
export default function TwitchBotTimersPage(): React.ReactElement {
const [timerConfig, setTimerConfig] = useModule(modules.twitchBotTimers);
export default function TwitchChatTimersPage(): React.ReactElement {
const [timerConfig, setTimerConfig] = useModule(modules.twitchChatTimers);
const [filter, setFilter] = useState('');
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
@ -317,7 +317,7 @@ export default function TwitchBotTimersPage(): React.ReactElement {
const filterLC = filter.toLowerCase();
const setTimer = (newName: string, data: TwitchBotTimer): void => {
const setTimer = (newName: string, data: TwitchChatTimer): void => {
switch (activeDialog.kind) {
case 'new':
void dispatch(

View File

@ -450,7 +450,7 @@ function TwitchEventsStep() {
const { t } = useTranslation();
const [userStatus, setUserStatus] = useState<helix.User | SyncError>(null);
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const [botConfig, setBotConfig] = useModule(modules.twitchBotConfig);
const [botConfig, setBotConfig] = useModule(modules.twitchChatConfig);
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const [authKeys, setAuthKeys] = useState<TwitchCredentials>(null);
const kv = useSelector((state: RootState) => state.api.client);

View File

@ -19,7 +19,6 @@ import {
Checkbox,
CheckboxIndicator,
Field,
FieldNote,
FlexRow,
InputBox,
Label,
@ -54,143 +53,27 @@ const Step = styled('li', {
},
});
function TwitchBotSettings() {
const [botConfig, setBotConfig, loadStatus] = useModule(
modules.twitchBotConfig,
function TwitchChatSettings() {
const [chatConfig, setChatConfig, loadStatus] = useModule(
modules.twitchChatConfig,
);
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const status = useStatus(loadStatus.save);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [revealBotToken, setRevealBotToken] = useState(false);
const active = twitchConfig?.enable_bot ?? false;
const disabled = !active || status?.type === 'pending';
const disabled = status?.type === 'pending';
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
void dispatch(setBotConfig(botConfig));
void dispatch(setChatConfig(chatConfig));
ev.preventDefault();
}}
>
<TextBlock>{t('pages.twitch-settings.bot-settings-copy')}</TextBlock>
<Field>
<FlexRow spacing={1}>
<Checkbox
checked={active}
onCheckedChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
enable_bot: !!ev,
}),
)
}
id="enable-bot"
>
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
</Checkbox>
<Label htmlFor="enable-bot">
{t('pages.twitch-settings.enable-bot')}
</Label>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label htmlFor="bot-channel">
{t('pages.twitch-settings.bot-channel')}
</Label>
<InputBox
type="text"
id="bot-channel"
required={active}
disabled={disabled}
value={botConfig?.channel ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
channel: ev.target.value,
}),
)
}
/>
</Field>
<SectionHeader>
{t('pages.twitch-settings.bot-info-header')}
</SectionHeader>
<Field size="fullWidth">
<Label htmlFor="bot-username">
{t('pages.twitch-settings.bot-username')}
</Label>
<InputBox
type="text"
id="bot-username"
required={active}
disabled={disabled}
value={botConfig?.username ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
username: ev.target.value,
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="bot-oauth">
{t('pages.twitch-settings.bot-oauth')}
<RevealLink value={revealBotToken} setter={setRevealBotToken} />
</Label>
<PasswordInputBox
reveal={revealBotToken}
id="bot-oauth"
required={active}
disabled={disabled}
value={botConfig?.oauth ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
oauth: ev.target.value,
}),
)
}
/>
<FieldNote>
<Trans i18nKey="pages.twitch-settings.bot-oauth-note">
<BrowserLink href="https://twitchapps.com/tmi/">
https://twitchapps.com/tmi/
</BrowserLink>
</Trans>
</FieldNote>
</Field>
<SectionHeader>
{t('pages.twitch-settings.bot-chat-header')}
</SectionHeader>
<Field size="fullWidth">
<Label htmlFor="bot-chat-history">
{t('pages.twitch-settings.bot-chat-history')}
</Label>
<InputBox
type="number"
id="bot-chat-history"
required={active}
disabled={disabled}
defaultValue={botConfig?.chat_history}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
chat_history: parseInt(ev.target.value, 10),
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="bot-chat-history">
{t('pages.twitch-settings.bot-chat-cooldown-tip')}
@ -198,13 +81,15 @@ function TwitchBotSettings() {
<InputBox
type="number"
id="bot-chat-history"
required={active}
required={true}
disabled={disabled}
defaultValue={botConfig ? botConfig.command_cooldown ?? 2 : undefined}
defaultValue={
chatConfig ? chatConfig.command_cooldown ?? 2 : undefined
}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchBotConfigChanged({
...botConfig,
apiReducer.actions.twitchChatConfigChanged({
...chatConfig,
command_cooldown: parseInt(ev.target.value, 10),
}),
)
@ -532,8 +417,8 @@ export default function TwitchSettingsPage(): React.ReactElement {
<TabButton value="eventsub">
{t('pages.twitch-settings.eventsub')}
</TabButton>
<TabButton value="bot-settings">
{t('pages.twitch-settings.bot-settings')}
<TabButton value="chat-settings">
{t('pages.twitch-settings.chat-settings')}
</TabButton>
</TabList>
<TabContent value="api-config">
@ -542,8 +427,8 @@ export default function TwitchSettingsPage(): React.ReactElement {
<TabContent value="eventsub">
<TwitchEventSubSettings />
</TabContent>
<TabContent value="bot-settings">
<TwitchBotSettings />
<TabContent value="chat-settings">
<TwitchChatSettings />
</TabContent>
</TabContainer>
</div>

2
go.mod
View File

@ -13,7 +13,7 @@ require (
github.com/gorilla/websocket v1.5.1
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/json-iterator/go v1.1.12
github.com/nicklaw5/helix/v2 v2.28.0
github.com/nicklaw5/helix/v2 v2.28.1
github.com/strimertul/kilovolt/v11 v11.0.1
github.com/urfave/cli/v2 v2.27.1
github.com/wailsapp/wails/v2 v2.8.0

4
go.sum
View File

@ -251,8 +251,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nicklaw5/helix/v2 v2.28.0 h1:BCpIh9gf/7dsTNyxzgY18VHpt9W6/t0zUioyuDhH6tA=
github.com/nicklaw5/helix/v2 v2.28.0/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/nicklaw5/helix/v2 v2.28.1 h1:bLVKMrZ0MiSgCLB3nsi7+OrhognsIusqvNL4XFoRG0A=
github.com/nicklaw5/helix/v2 v2.28.1/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=

23
migrations/common.go Normal file
View File

@ -0,0 +1,23 @@
package migrations
import (
"errors"
"git.sr.ht/~ashkeel/strimertul/database"
)
func renameKey(db database.Database, oldKey, newKey string, ignoreMissing bool) error {
value, err := db.GetKey(oldKey)
if err != nil {
if ignoreMissing && errors.Is(err, database.ErrEmptyKey) {
return nil
}
return err
}
if err = db.PutKey(newKey, value); err != nil {
return err
}
return db.RemoveKey(oldKey)
}

72
migrations/migration.go Normal file
View File

@ -0,0 +1,72 @@
package migrations
import (
"bytes"
"errors"
"fmt"
"strconv"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
)
const (
SchemaKey = "strimertul/schema-version"
SchemaVersion = 4
)
// GetCurrentSchemaVersion returns the current schema version
// from the database, or 0 if it's not set (v3.x.x or earlier)
func GetCurrentSchemaVersion(db database.Database) (int, error) {
versionStr, err := db.GetKey(SchemaKey)
if err != nil {
if errors.Is(err, database.ErrEmptyKey) {
return 0, nil
}
}
if versionStr == "" {
return 0, nil
}
return strconv.Atoi(versionStr)
}
func Run(driver database.Driver, db database.Database, logger *zap.Logger) (err error) {
// Make a backup of the database
var buffer bytes.Buffer
if err = driver.Backup(&buffer); err != nil {
return fmt.Errorf("failed to backup database: %s", err)
}
// Restore the backup if an error occurs
defer func() {
if err != nil {
restoreErr := driver.Restore(bytes.NewReader(buffer.Bytes()))
if restoreErr != nil {
logger.Error("Failed to restore database from backup", zap.Error(restoreErr))
}
}
}()
// Get the current schema version
var currentVersion int
currentVersion, err = GetCurrentSchemaVersion(db)
if err != nil {
return
}
// Migrate from v3.x.x to v4.0.0
if currentVersion < 4 {
if err = migrateToV4(db, logger); err != nil {
return
}
}
// Set the new schema version
if err = db.PutKey(SchemaKey, strconv.Itoa(SchemaVersion)); err != nil {
return
}
return
}

80
migrations/v3_v4.go Normal file
View File

@ -0,0 +1,80 @@
package migrations
import (
"encoding/json"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch/alerts"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch/timers"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
)
func migrateToV4(db database.Database, logger *zap.Logger) error {
// Rename keys that have no schema changes
for oldKey, newKey := range map[string]string{
"twitch/bot-modules/timers/config": timers.ConfigKey,
"twitch/bot-modules/alerts/config": alerts.ConfigKey,
"twitch/bot-custom-commands": chat.CustomCommandsKey,
} {
if err := renameKey(db, oldKey, newKey, true); err != nil {
return err
}
}
// Clear old event keys and IRC-related keys
for _, key := range []string{
"twitch/chat-activity",
"twitch/chat-history",
"twitch/@send-chat-message",
"twitch/bot/@send-message",
"twitch/ev/chat-message",
"twitch/ev/eventsub-event",
} {
if err := db.RemoveKey(key); err != nil {
return err
}
}
// Migrate bot config to chat config
var botConfig struct {
CommandCooldown int `json:"command_cooldown"`
}
if err := db.GetJSON("twitch/bot-config", &botConfig); err != nil {
return err
}
if err := db.PutJSON(chat.ConfigKey, chat.Config{
CommandCooldown: botConfig.CommandCooldown,
}); err != nil {
return err
}
// Migrate eventsub history to their new keys
type eventSubNotification struct {
Subscription helix.EventSubSubscription `json:"subscription"`
Event json.RawMessage `json:"event"`
}
var eventsubHistory []eventSubNotification
if err := db.GetJSON("twitch/eventsub-history", &eventsubHistory); err != nil {
return err
}
eventsubHistoryMap := make(map[string][]eventSubNotification)
for _, notification := range eventsubHistory {
key := eventsub.HistoryKeyPrefix + notification.Subscription.Type
eventsubHistoryMap[key] = append(eventsubHistoryMap[key], notification)
}
for key, notifications := range eventsubHistoryMap {
if err := db.PutJSON(key, notifications); err != nil {
return err
}
}
// Clear old eventsub history key
if err := db.RemoveKey("twitch/eventsub-history"); err != nil {
return err
}
return nil
}

View File

@ -1,6 +1,7 @@
package alerts
import (
"context"
"sync"
"text/template"
@ -34,6 +35,7 @@ const (
type Module struct {
Config Config
ctx context.Context
db database.Database
logger *zap.Logger
templater template2.Engine
@ -46,8 +48,9 @@ type Module struct {
pendingSubs map[string]subMixedEvent
}
func Setup(db database.Database, logger *zap.Logger, templater template2.Engine) *Module {
func Setup(ctx context.Context, db database.Database, logger *zap.Logger, templater template2.Engine) *Module {
mod := &Module{
ctx: ctx,
db: db,
logger: logger,
templater: templater,

View File

@ -3,9 +3,6 @@ package chat
const ConfigKey = "twitch/chat/config"
type Config struct {
// How many messages to keep in twitch/chat-history
ChatHistory int `json:"chat_history" desc:"How many messages to keep in the chat history key"`
// Global command cooldown in seconds
CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"`
}

View File

@ -1,8 +1,6 @@
package chat
const (
EventKey = "twitch/chat/ev/message"
HistoryKey = "twitch/chat/history"
ActivityKey = "twitch/chat/activity"
CustomCommandsKey = "twitch/chat/custom-commands"
WriteMessageRPC = "twitch/chat/@send-message"

View File

@ -24,13 +24,12 @@ type Module struct {
Config Config
ctx context.Context
db *database.LocalDBClient
db database.Database
api *helix.Client
user *helix.User
user helix.User
logger *zap.Logger
templater template.Engine
lastMessage *sync.RWSync[time.Time]
chatHistory *sync.Slice[helix.EventSubChannelChatMessageEvent]
commands *sync.Map[string, Command]
customCommands *sync.Map[string, CustomCommand]
@ -43,7 +42,7 @@ type Module struct {
cancelChatMessageSub database.CancelFunc
}
func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, user *helix.User, logger *zap.Logger, templater template.Engine) *Module {
func Setup(ctx context.Context, db database.Database, api *helix.Client, user helix.User, logger *zap.Logger, templater template.Engine) *Module {
newContext, cancel := context.WithCancel(ctx)
mod := &Module{
@ -67,7 +66,6 @@ func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, u
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
if errors.Is(err, database.ErrEmptyKey) {
mod.Config = Config{
ChatHistory: 0,
CommandCooldown: 2,
}
} else {
@ -126,15 +124,17 @@ func (mod *Module) Close() {
}
func (mod *Module) onChatMessage(newValue string) {
var chatMessage helix.EventSubChannelChatMessageEvent
if err := json.Unmarshal([]byte(newValue), &chatMessage); err != nil {
var chatMessage struct {
Event helix.EventSubChannelChatMessageEvent `json:"event"`
}
if err := json.UnmarshalFromString(newValue, &chatMessage); err != nil {
mod.logger.Error("Failed to decode incoming chat message", zap.Error(err))
return
}
// TODO Command cooldown logic here!
lowercaseMessage := strings.TrimSpace(strings.ToLower(chatMessage.Message.Text))
lowercaseMessage := strings.TrimSpace(strings.ToLower(chatMessage.Event.Message.Text))
// Check if it's a command
if strings.HasPrefix(lowercaseMessage, "!") {
@ -150,7 +150,7 @@ func (mod *Module) onChatMessage(newValue string) {
if parts[0] != cmd {
continue
}
go data.Handler(chatMessage)
go data.Handler(chatMessage.Event)
mod.lastMessage.Set(time.Now())
}
}
@ -168,25 +168,9 @@ func (mod *Module) onChatMessage(newValue string) {
if parts[0] != lc {
continue
}
go cmdCustom(mod, cmd, data, chatMessage)
go cmdCustom(mod, cmd, data, chatMessage.Event)
mod.lastMessage.Set(time.Now())
}
err := mod.db.PutJSON(EventKey, chatMessage)
if err != nil {
mod.logger.Warn("Could not save chat message to key", zap.Error(err))
}
if mod.Config.ChatHistory > 0 {
history := mod.chatHistory.Get()
if len(history) >= mod.Config.ChatHistory {
history = history[len(history)-mod.Config.ChatHistory+1:]
}
mod.chatHistory.Set(append(history, chatMessage))
err = mod.db.PutJSON(HistoryKey, mod.chatHistory.Get())
if err != nil {
mod.logger.Warn("Could not save message to chat history", zap.Error(err))
}
}
}
func (mod *Module) handleWriteMessageRPC(value string) {

View File

@ -6,6 +6,12 @@ import (
"fmt"
"time"
"git.sr.ht/~ashkeel/strimertul/twitch/timers"
"git.sr.ht/~ashkeel/strimertul/twitch/alerts"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
@ -106,6 +112,10 @@ type Client struct {
User helix.User
Logger *zap.Logger
Chat *chat.Module
Alerts *alerts.Module
Timers *timers.Module
eventSub *eventsub.Client
server *webserver.WebServer
ctx context.Context
@ -142,35 +152,43 @@ func newClient(config twitch.Config, db database.Database, server *webserver.Web
server: server,
}
if config.Enabled {
var err error
client.API, err = twitch.GetHelixAPI(db)
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
server.RegisterRoute(twitch.CallbackRoute, client)
if userClient, err := twitch.GetUserClient(db, true); err == nil {
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
client.Logger.Error("Failed looking up user", zap.Error(err))
} else if len(users.Data.Users) < 1 {
client.Logger.Error("No users found, please authenticate in Twitch configuration -> Events")
} else {
client.User = users.Data.Users[0]
client.eventSub, err = eventsub.Setup(ctx, userClient, client.User, db, logger)
if err != nil {
client.Logger.Error("Failed to setup EventSub", zap.Error(err))
}
}
} else {
client.Logger.Warn("Twitch user not identified, this will break most features")
}
go client.runStatusPoll()
if !config.Enabled {
return client, nil
}
var err error
client.API, err = twitch.GetHelixAPI(db)
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
server.RegisterRoute(twitch.CallbackRoute, client)
if userClient, err := twitch.GetUserClient(db, true); err == nil {
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
client.Logger.Error("Failed looking up user", zap.Error(err))
} else if len(users.Data.Users) < 1 {
client.Logger.Error("No users found, please authenticate in Twitch configuration -> Events")
} else {
client.Logger.Info("Twitch user identified", zap.String("user", users.Data.Users[0].ID))
client.User = users.Data.Users[0]
client.eventSub, err = eventsub.Setup(ctx, userClient, client.User, db, logger)
if err != nil {
client.Logger.Error("Failed to setup EventSub", zap.Error(err))
}
tpl := client.GetTemplateEngine()
client.Chat = chat.Setup(ctx, db, userClient, client.User, logger, tpl)
client.Alerts = alerts.Setup(ctx, db, logger, tpl)
client.Timers = timers.Setup(ctx, db, logger)
}
} else {
client.Logger.Warn("Twitch user not identified, this will break most features")
}
go client.runStatusPoll()
return client, nil
}
@ -220,5 +238,15 @@ func (c *Client) Close() error {
c.server.UnregisterRoute(twitch.CallbackRoute)
defer c.cancel()
if c.Chat != nil {
c.Chat.Close()
}
if c.Alerts != nil {
c.Alerts.Close()
}
if c.Timers != nil {
c.Timers.Close()
}
return nil
}

View File

@ -7,9 +7,6 @@ type Config struct {
// Enable subsystem
Enabled bool `json:"enabled" desc:"Enable subsystem"`
// Enable the chatbot
EnableBot bool `json:"enable_bot" desc:"Enable the chatbot"`
// Twitch API App Client ID
APIClientID string `json:"api_client_id" desc:"Twitch API App Client ID"`

View File

@ -32,16 +32,6 @@ var Keys = interfaces.KeyMap{
Description: "Configuration for chat-related features",
Type: reflect.TypeOf(chat.Config{}),
},
chat.EventKey: interfaces.KeyDef{
Description: "On chat message received",
Type: reflect.TypeOf(helix.EventSubChannelChatMessageEvent{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
chat.HistoryKey: interfaces.KeyDef{
Description: "Last chat messages received",
Type: reflect.TypeOf([]helix.EventSubChannelChatMessageEvent{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
chat.ActivityKey: interfaces.KeyDef{
Description: "Number of chat messages in the last minute",
Type: reflect.TypeOf(0),

View File

@ -183,7 +183,6 @@ func (c *Client) processEvent(message WebsocketMessage) {
eventKey := fmt.Sprintf("%s%s", EventKeyPrefix, notificationData.Subscription.Type)
historyKey := fmt.Sprintf("%s%s", HistoryKeyPrefix, notificationData.Subscription.Type)
err = c.db.PutJSON(eventKey, notificationData)
c.logger.Info("Stored event", zap.String("key", eventKey), zap.String("notification-type", notificationData.Subscription.Type))
if err != nil {
c.logger.Error("Error storing event to database", zap.String("key", eventKey), zap.Error(err))
}

View File

@ -25,6 +25,7 @@ var scopes = []string{
"user:bot",
"user:manage:whispers",
"user:read:chat",
"user:write:chat",
"user_read",
"whispers:edit",
"whispers:read",

View File

@ -1,6 +1,7 @@
package timers
import (
"context"
"math/rand"
"time"
@ -24,14 +25,16 @@ type Module struct {
logger *zap.Logger
db database.Database
ctx context.Context
cancelTimerSub database.CancelFunc
}
func Setup(db database.Database, logger *zap.Logger) *Module {
func Setup(ctx context.Context, db database.Database, logger *zap.Logger) *Module {
mod := &Module{
lastTrigger: sync.NewMap[string, time.Time](),
messages: sync.NewSlice[int](),
db: db,
ctx: ctx,
logger: logger,
}