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: Strimertül is a single executable app that provides the following:
- HTTP server for serving static assets and a websocket API - 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 - 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). 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 ## 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`). 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" "runtime/debug"
"strconv" "strconv"
"git.sr.ht/~ashkeel/strimertul/migrations"
"git.sr.ht/~ashkeel/strimertul/twitch/client" "git.sr.ht/~ashkeel/strimertul/twitch/client"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
@ -88,6 +90,12 @@ func (a *App) startup(ctx context.Context) {
return 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 // Initialize components
if err := a.initializeComponents(); err != nil { if err := a.initializeComponents(); err != nil {
a.showFatalError(err, "Failed to initialize required component") a.showFatalError(err, "Failed to initialize required component")

View File

@ -24,9 +24,9 @@
}, },
"twitch": { "twitch": {
"configuration": "Configuration", "configuration": "Configuration",
"bot-commands": "Chat commands", "chat-commands": "Chat commands",
"bot-timers": "Chat timers", "chat-timers": "Chat timers",
"bot-alerts": "Chat alerts" "chat-alerts": "Chat alerts"
}, },
"loyalty": { "loyalty": {
"configuration": "Configuration", "configuration": "Configuration",
@ -66,18 +66,16 @@
"apiguide-4": "Once made, create a <1>New Secret</1>, then copy both fields below and save!", "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-id": "App Client ID",
"app-client-secret": "App Client Secret", "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-subheader": "Application info",
"api-configuration": "API access", "api-configuration": "API access",
"eventsub": "Events", "eventsub": "Events",
"bot-settings": "Bot settings", "chat-settings": "Chat settings",
"enable-bot": "Enable Twitch bot", "enable-bot": "Enable chat features",
"bot-channel": "Twitch channel", "bot-channel": "Twitch channel",
"bot-username": "Twitch account username", "bot-username": "Twitch account username",
"bot-oauth": "Authorization token", "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-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-header": "Chat settings",
"bot-chat-history": "How many messages to keep in history (0 to disable)", "bot-chat-history": "How many messages to keep in history (0 to disable)",
"events": { "events": {
@ -107,7 +105,7 @@
"bot-chat-cooldown-tip": "Global chat cooldown for commands (in seconds)" "bot-chat-cooldown-tip": "Global chat cooldown for commands (in seconds)"
}, },
"botcommands": { "botcommands": {
"title": "Bot commands", "title": "Chat commands",
"desc": "Define custom chat commands to set up autoresponders, counters, etc.", "desc": "Define custom chat commands to set up autoresponders, counters, etc.",
"add-button": "New command", "add-button": "New command",
"search-placeholder": "Search command by name", "search-placeholder": "Search command by name",
@ -135,12 +133,12 @@
"streamer": "Streamer only" "streamer": "Streamer only"
}, },
"remove-command-title": "Remove command {{name}}?", "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-already-in-use": "Command name already in use",
"command-invalid-format": "The response template contains errors" "command-invalid-format": "The response template contains errors"
}, },
"bottimers": { "bottimers": {
"title": "Bot timers", "title": "Chat timers",
"desc": "Define reminders such as checking out your social media or ongoing events", "desc": "Define reminders such as checking out your social media or ongoing events",
"add-button": "New timer", "add-button": "New timer",
"search-placeholder": "Search timer by name", "search-placeholder": "Search timer by name",
@ -301,7 +299,7 @@
"landing": "Welcome", "landing": "Welcome",
"twitch-config": "Twitch integration", "twitch-config": "Twitch integration",
"twitch-events": "Twitch events", "twitch-events": "Twitch events",
"twitch-bot": "Twitch bot", "twitch-bot": "Twitch chat",
"done": "All done!" "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.", "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-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", "twitch-complete": "Complete Twitch integration",
"done-header": "You're all set!", "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-p2": "If you have questions or issues, please reach out at any of these places:",
"done-button": "Complete onboarding", "done-button": "Complete onboarding",
"done-p3": "Click the button below to finish the onboarding and go to {{APPNAME}}'s dashboard.", "done-p3": "Click the button below to finish the onboarding and go to {{APPNAME}}'s dashboard.",

View File

@ -74,9 +74,9 @@
"extensions": "Estensioni" "extensions": "Estensioni"
}, },
"twitch": { "twitch": {
"bot-alerts": "Avvisi in chat", "chat-alerts": "Avvisi in chat",
"bot-commands": "Comandi bot", "chat-commands": "Comandi chat",
"bot-timers": "Timer bot", "chat-timers": "Timer chat",
"configuration": "Configurazione" "configuration": "Configurazione"
} }
}, },
@ -332,10 +332,9 @@
"bot-channel": "Canale Twitch", "bot-channel": "Canale Twitch",
"bot-chat-header": "Impostazioni chat", "bot-chat-header": "Impostazioni chat",
"bot-chat-history": "Quanti messaggi tenere nello storico (0 per disabilitare)", "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": "Token di autorizzazione",
"bot-oauth-note": "Puoi ottenerlo accedendo con l'account del bot e andando qui: <1>https://twitchapps.com/tmi/</1>", "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-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", "bot-username": "Nome utente dell'account Twitch",
"enable": "Abilita integrazione Twitch", "enable": "Abilita integrazione Twitch",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
go.mod
View File

@ -13,7 +13,7 @@ require (
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/json-iterator/go v1.1.12 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/strimertul/kilovolt/v11 v11.0.1
github.com/urfave/cli/v2 v2.27.1 github.com/urfave/cli/v2 v2.27.1
github.com/wailsapp/wails/v2 v2.8.0 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/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-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/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.1 h1:bLVKMrZ0MiSgCLB3nsi7+OrhognsIusqvNL4XFoRG0A=
github.com/nicklaw5/helix/v2 v2.28.0/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY= 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 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= 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 package alerts
import ( import (
"context"
"sync" "sync"
"text/template" "text/template"
@ -34,6 +35,7 @@ const (
type Module struct { type Module struct {
Config Config Config Config
ctx context.Context
db database.Database db database.Database
logger *zap.Logger logger *zap.Logger
templater template2.Engine templater template2.Engine
@ -46,8 +48,9 @@ type Module struct {
pendingSubs map[string]subMixedEvent 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{ mod := &Module{
ctx: ctx,
db: db, db: db,
logger: logger, logger: logger,
templater: templater, templater: templater,

View File

@ -3,9 +3,6 @@ package chat
const ConfigKey = "twitch/chat/config" const ConfigKey = "twitch/chat/config"
type Config struct { 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 // Global command cooldown in seconds
CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"` CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"`
} }

View File

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

View File

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

View File

@ -6,6 +6,12 @@ import (
"fmt" "fmt"
"time" "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"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub" "git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
@ -106,6 +112,10 @@ type Client struct {
User helix.User User helix.User
Logger *zap.Logger Logger *zap.Logger
Chat *chat.Module
Alerts *alerts.Module
Timers *timers.Module
eventSub *eventsub.Client eventSub *eventsub.Client
server *webserver.WebServer server *webserver.WebServer
ctx context.Context ctx context.Context
@ -142,35 +152,43 @@ func newClient(config twitch.Config, db database.Database, server *webserver.Web
server: server, server: server,
} }
if config.Enabled { if !config.Enabled {
var err error return client, nil
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()
} }
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 return client, nil
} }
@ -220,5 +238,15 @@ func (c *Client) Close() error {
c.server.UnregisterRoute(twitch.CallbackRoute) c.server.UnregisterRoute(twitch.CallbackRoute)
defer c.cancel() 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 return nil
} }

View File

@ -7,9 +7,6 @@ type Config struct {
// Enable subsystem // Enable subsystem
Enabled bool `json:"enabled" desc:"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 // Twitch API App Client ID
APIClientID string `json:"api_client_id" desc:"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", Description: "Configuration for chat-related features",
Type: reflect.TypeOf(chat.Config{}), 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{ chat.ActivityKey: interfaces.KeyDef{
Description: "Number of chat messages in the last minute", Description: "Number of chat messages in the last minute",
Type: reflect.TypeOf(0), 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) eventKey := fmt.Sprintf("%s%s", EventKeyPrefix, notificationData.Subscription.Type)
historyKey := fmt.Sprintf("%s%s", HistoryKeyPrefix, notificationData.Subscription.Type) historyKey := fmt.Sprintf("%s%s", HistoryKeyPrefix, notificationData.Subscription.Type)
err = c.db.PutJSON(eventKey, notificationData) 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 { if err != nil {
c.logger.Error("Error storing event to database", zap.String("key", eventKey), zap.Error(err)) 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:bot",
"user:manage:whispers", "user:manage:whispers",
"user:read:chat", "user:read:chat",
"user:write:chat",
"user_read", "user_read",
"whispers:edit", "whispers:edit",
"whispers:read", "whispers:read",

View File

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