1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-30 02:40:33 +00:00

Compare commits

...

2 commits

Author SHA1 Message Date
Ash Keel
a06b9457ea
refactor: ctx over cancel 2024-03-13 00:50:59 +01:00
Ash Keel
31d44b950e
feat: migration and more chat stuff 2024-03-12 23:39:18 +01:00
30 changed files with 467 additions and 460 deletions

View file

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The required set of permissions has changed. Existing users must re-authenticate their users to the app connected to strimertül. - The required set of permissions has changed. Existing users must re-authenticate their users to the app connected to strimertül.
- The `twitch/ev/eventsub-event` and `twitch/eventsub-history` keys have been replaced by a set of keys in the format `twitch/ev/eventsub-event/<event-id>` and `twitch/eventsub-history/<event-id>`. Users of the old system will have to adjust their logic. A simple trick is to change from get/subscribing from a single key to the entire prefix. The data structure is the same. - The `twitch/ev/eventsub-event` and `twitch/eventsub-history` keys have been replaced by a set of keys in the format `twitch/ev/eventsub-event/<event-id>` and `twitch/eventsub-history/<event-id>`. Users of the old system will have to adjust their logic. A simple trick is to change from get/subscribing from a single key to the entire prefix. The data structure is the same.
- A lot of keys for internal use have been changed, make sure to check the new reference for fixing up any integrations you might have. A migration process will convert v3 keys to v4 keys.
## 3.3.1 - 2023-11-12 ## 3.3.1 - 2023-11-12

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`).

21
app.go
View file

@ -12,15 +12,11 @@ import (
"runtime/debug" "runtime/debug"
"strconv" "strconv"
"git.sr.ht/~ashkeel/strimertul/twitch/client"
"github.com/wailsapp/wails/v2/pkg/options"
kv "github.com/strimertul/kilovolt/v11"
"git.sr.ht/~ashkeel/containers/sync" "git.sr.ht/~ashkeel/containers/sync"
"github.com/nicklaw5/helix/v2" "github.com/nicklaw5/helix/v2"
kv "github.com/strimertul/kilovolt/v11"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
@ -28,7 +24,9 @@ import (
"git.sr.ht/~ashkeel/strimertul/database" "git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/docs" "git.sr.ht/~ashkeel/strimertul/docs"
"git.sr.ht/~ashkeel/strimertul/loyalty" "git.sr.ht/~ashkeel/strimertul/loyalty"
"git.sr.ht/~ashkeel/strimertul/migrations"
"git.sr.ht/~ashkeel/strimertul/twitch" "git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/client"
"git.sr.ht/~ashkeel/strimertul/webserver" "git.sr.ht/~ashkeel/strimertul/webserver"
) )
@ -88,6 +86,12 @@ func (a *App) startup(ctx context.Context) {
return return
} }
// Check for migrations
if err := migrations.Run(a.driver, a.db, logger.With(zap.String("operation", "migration"))); 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")
@ -148,7 +152,7 @@ func (a *App) initializeComponents() error {
} }
// Create twitch client // Create twitch client
a.twitchManager, err = client.NewManager(a.db, a.httpServer, logger) a.twitchManager, err = client.NewManager(a.ctx, a.db, a.httpServer, logger)
if err != nil { if err != nil {
return fmt.Errorf("could not initialize twitch client: %w", err) return fmt.Errorf("could not initialize twitch client: %w", err)
} }
@ -192,9 +196,6 @@ func (a *App) stop(_ context.Context) {
if a.loyaltyManager != nil { if a.loyaltyManager != nil {
warnOnError(a.loyaltyManager.Close(), "Could not cleanly close loyalty manager") warnOnError(a.loyaltyManager.Close(), "Could not cleanly close loyalty manager")
} }
if a.twitchManager != nil {
warnOnError(a.twitchManager.Close(), "Could not cleanly close twitch client")
}
if a.httpServer != nil { if a.httpServer != nil {
warnOnError(a.httpServer.Close(), "Could not cleanly close HTTP server") warnOnError(a.httpServer.Close(), "Could not cleanly close HTTP server")
} }

View file

@ -1,6 +1,7 @@
package database package database
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
@ -24,13 +25,20 @@ var (
type Database interface { type Database interface {
GetKey(key string) (string, error) GetKey(key string) (string, error)
PutKey(key string, data string) error PutKey(key string, data string) error
RemoveKey(key string) error
SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (cancelFn CancelFunc, err error) SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (cancelFn CancelFunc, err error)
SubscribeKey(key string, fn func(string)) (cancelFn CancelFunc, err error) SubscribeKey(key string, fn func(string)) (cancelFn CancelFunc, err error)
GetJSON(key string, dst any) error
SubscribePrefixContext(ctx context.Context, fn kv.SubscriptionCallback, prefixes ...string) error
SubscribeKeyContext(ctx context.Context, key string, fn func(string)) error
GetAll(prefix string) (map[string]string, error) GetAll(prefix string) (map[string]string, error)
GetJSON(key string, dst any) error
PutJSON(key string, data any) error PutJSON(key string, data any) error
PutJSONBulk(kvs map[string]any) error PutJSONBulk(kvs map[string]any) error
RemoveKey(key string) error
Hub() *kv.Hub Hub() *kv.Hub
} }
@ -87,6 +95,32 @@ func (mod *LocalDBClient) PutKey(key string, data string) error {
return err return err
} }
func (mod *LocalDBClient) SubscribePrefixContext(ctx context.Context, fn kv.SubscriptionCallback, prefixes ...string) error {
cancel, err := mod.SubscribePrefix(fn, prefixes...)
if err != nil {
return err
}
go func() {
<-ctx.Done()
cancel()
}()
return nil
}
func (mod *LocalDBClient) SubscribeKeyContext(ctx context.Context, key string, fn func(string)) error {
cancel, err := mod.SubscribeKey(key, fn)
if err != nil {
return err
}
go func() {
<-ctx.Done()
cancel()
}()
return nil
}
func (mod *LocalDBClient) SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (cancelFn CancelFunc, err error) { func (mod *LocalDBClient) SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (cancelFn CancelFunc, err error) {
var ids []int64 var ids []int64
for _, prefix := range prefixes { for _, prefix := range prefixes {

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

@ -158,7 +158,9 @@ function TwitchEvent({ data }: { data: EventSubNotification }) {
let content: JSX.Element | string; let content: JSX.Element | string;
const message = unwrapEvent(data); const message = unwrapEvent(data);
let date = data.date ? new Date(data.date) : null; let date = data.date
? new Date(data.date)
: new Date(data.subscription.created_at);
switch (message.type) { switch (message.type) {
case EventSubNotificationType.Followed: { case EventSubNotificationType.Followed: {
content = ( content = (
@ -382,10 +384,16 @@ function TwitchEventLog({ events }: { events: EventSubNotification[] }) {
{events {events
.filter((ev) => supportedMessages.includes(ev.subscription.type)) .filter((ev) => supportedMessages.includes(ev.subscription.type))
.sort((a, b) => .sort((a, b) =>
a.date && b.date ? Date.parse(b.date) - Date.parse(a.date) : 0, a.date && b.date
? Date.parse(b.date) - Date.parse(a.date)
: Date.parse(b.subscription.created_at) -
Date.parse(a.subscription.created_at),
) )
.map((ev) => ( .map((ev) => (
<TwitchEvent key={`${ev.subscription.id}-${ev.date}`} data={ev} /> <TwitchEvent
key={`${ev.subscription.id}-${ev.subscription.created_at}`}
data={ev}
/>
))} ))}
</EventListContainer> </EventListContainer>
</Scrollbar> </Scrollbar>
@ -433,6 +441,19 @@ function TwitchSection() {
const kv = useAppSelector((state) => state.api.client); const kv = useAppSelector((state) => state.api.client);
const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]); const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]);
const keyfn = (ev: EventSubNotification) =>
ev.subscription.id + ev.subscription.created_at;
const setCleanTwitchEvents = (events: EventSubNotification[]) => {
const eventKeys = events.map(keyfn);
// Clean up duplicates before setting to state
const uniqueEvents = events.filter(
(ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos,
);
setTwitchEvents(uniqueEvents);
};
const loadRecentEvents = async () => { const loadRecentEvents = async () => {
const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/'); const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/');
const events = Object.values(keymap) const events = Object.values(keymap)
@ -440,7 +461,7 @@ function TwitchSection() {
.flat() .flat()
.sort((a, b) => Date.parse(b.date) - Date.parse(a.date)); .sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
setTwitchEvents(events); setCleanTwitchEvents(events);
}; };
useEffect(() => { useEffect(() => {
@ -448,7 +469,7 @@ function TwitchSection() {
const onKeyChange = (value: string) => { const onKeyChange = (value: string) => {
const event = JSON.parse(value) as EventSubNotification; const event = JSON.parse(value) as EventSubNotification;
void setTwitchEvents((prev) => [event, ...prev]); void setCleanTwitchEvents((prev) => [event, ...prev]);
}; };
void kv.subscribePrefix('twitch/ev/eventsub-event/', onKeyChange); void kv.subscribePrefix('twitch/ev/eventsub-event/', onKeyChange);

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
}

82
migrations/v3_v4.go Normal file
View file

@ -0,0 +1,82 @@
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 {
logger.Info("Migrating database from v3 to v4")
// 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,17 +1,16 @@
package alerts package alerts
import ( import (
"context"
"sync" "sync"
"text/template" "text/template"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
template2 "git.sr.ht/~ashkeel/strimertul/twitch/template"
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
"go.uber.org/zap" "go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database" "git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
template2 "git.sr.ht/~ashkeel/strimertul/twitch/template"
) )
var json = jsoniter.ConfigFastest var json = jsoniter.ConfigFastest
@ -34,20 +33,19 @@ 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
templates templateCacheMap templates templateCacheMap
cancelAlertSub database.CancelFunc
cancelTwitchEventSub database.CancelFunc
pendingMux sync.Mutex pendingMux sync.Mutex
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,
@ -56,8 +54,7 @@ func Setup(db database.Database, logger *zap.Logger, templater template2.Engine)
} }
// Load config from database // Load config from database
err := db.GetJSON(ConfigKey, &mod.Config) if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
if err != nil {
logger.Debug("Config load error", zap.Error(err)) logger.Debug("Config load error", zap.Error(err))
mod.Config = Config{} mod.Config = Config{}
// Save empty config // Save empty config
@ -69,7 +66,7 @@ func Setup(db database.Database, logger *zap.Logger, templater template2.Engine)
mod.compileTemplates() mod.compileTemplates()
mod.cancelAlertSub, err = db.SubscribeKey(ConfigKey, func(value string) { if err := db.SubscribeKeyContext(ctx, ConfigKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config) err := json.UnmarshalFromString(value, &mod.Config)
if err != nil { if err != nil {
logger.Warn("Error loading alert config", zap.Error(err)) logger.Warn("Error loading alert config", zap.Error(err))
@ -77,13 +74,11 @@ func Setup(db database.Database, logger *zap.Logger, templater template2.Engine)
logger.Info("Reloaded alert config") logger.Info("Reloaded alert config")
} }
mod.compileTemplates() mod.compileTemplates()
}) }); err != nil {
if err != nil {
logger.Error("Could not set-up bot alert reload subscription", zap.Error(err)) logger.Error("Could not set-up bot alert reload subscription", zap.Error(err))
} }
mod.cancelTwitchEventSub, err = db.SubscribePrefix(mod.onEventSubEvent, eventsub.EventKeyPrefix) if err := db.SubscribePrefixContext(ctx, mod.onEventSubEvent, eventsub.EventKeyPrefix); err != nil {
if err != nil {
logger.Error("Could not setup twitch alert subscription", zap.Error(err)) logger.Error("Could not setup twitch alert subscription", zap.Error(err))
} }
@ -91,12 +86,3 @@ func Setup(db database.Database, logger *zap.Logger, templater template2.Engine)
return mod return mod
} }
func (m *Module) Close() {
if m.cancelAlertSub != nil {
m.cancelAlertSub()
}
if m.cancelTwitchEventSub != nil {
m.cancelTwitchEventSub()
}
}

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,30 +24,24 @@ 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]
customTemplates *sync.Map[string, *textTemplate.Template] customTemplates *sync.Map[string, *textTemplate.Template]
customFunctions textTemplate.FuncMap customFunctions textTemplate.FuncMap
cancelContext context.CancelFunc cancelContext context.CancelFunc
cancelUpdateSub database.CancelFunc
cancelWriteRPCSub 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)
mod := &Module{ mod := &Module{
ctx: newContext, ctx: ctx,
db: db, db: db,
api: api, api: api,
user: user, user: user,
@ -59,15 +53,12 @@ func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, u
customCommands: sync.NewMap[string, CustomCommand](), customCommands: sync.NewMap[string, CustomCommand](),
customTemplates: sync.NewMap[string, *textTemplate.Template](), customTemplates: sync.NewMap[string, *textTemplate.Template](),
customFunctions: make(textTemplate.FuncMap), customFunctions: make(textTemplate.FuncMap),
cancelContext: cancel,
} }
// Get config // Get config
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 {
@ -75,16 +66,13 @@ func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, u
} }
} }
var err error if err := db.SubscribeKeyContext(ctx, eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage); err != nil {
mod.cancelChatMessageSub, err = db.SubscribeKey(eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage)
if err != nil {
logger.Error("Could not subscribe to chat messages", zap.Error(err)) logger.Error("Could not subscribe to chat messages", zap.Error(err))
} }
// Load custom commands // Load custom commands
var customCommands map[string]CustomCommand var customCommands map[string]CustomCommand
err = db.GetJSON(CustomCommandsKey, &customCommands) if err := db.GetJSON(CustomCommandsKey, &customCommands); err != nil {
if err != nil {
if errors.Is(err, database.ErrEmptyKey) { if errors.Is(err, database.ErrEmptyKey) {
customCommands = make(map[string]CustomCommand) customCommands = make(map[string]CustomCommand)
} else { } else {
@ -93,48 +81,33 @@ func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, u
} }
mod.customCommands.Set(customCommands) mod.customCommands.Set(customCommands)
err = mod.updateTemplates() if err := mod.updateTemplates(); err != nil {
if err != nil {
logger.Error("Failed to parse custom commands", zap.Error(err)) logger.Error("Failed to parse custom commands", zap.Error(err))
} }
mod.cancelUpdateSub, err = db.SubscribeKey(CustomCommandsKey, mod.updateCommands)
if err != nil { if err := db.SubscribeKeyContext(ctx, CustomCommandsKey, mod.updateCommands); err != nil {
logger.Error("Could not set-up chat command reload subscription", zap.Error(err)) logger.Error("Could not set-up chat command reload subscription", zap.Error(err))
} }
mod.cancelWriteRPCSub, err = db.SubscribeKey(WriteMessageRPC, mod.handleWriteMessageRPC)
if err != nil { if err := db.SubscribeKeyContext(ctx, WriteMessageRPC, mod.handleWriteMessageRPC); err != nil {
logger.Error("Could not set-up chat command reload subscription", zap.Error(err)) logger.Error("Could not set-up chat command reload subscription", zap.Error(err))
} }
return mod return mod
} }
func (mod *Module) Close() {
if mod.cancelChatMessageSub != nil {
mod.cancelChatMessageSub()
}
if mod.cancelUpdateSub != nil {
mod.cancelUpdateSub()
}
if mod.cancelWriteRPCSub != nil {
mod.cancelWriteRPCSub()
}
mod.cancelContext()
}
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 +123,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 +141,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,27 +6,27 @@ import (
"fmt" "fmt"
"time" "time"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/containers/sync" "git.sr.ht/~ashkeel/containers/sync"
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2" "github.com/nicklaw5/helix/v2"
"go.uber.org/zap" "go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database" "git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/alerts"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/twitch/timers"
"git.sr.ht/~ashkeel/strimertul/webserver" "git.sr.ht/~ashkeel/strimertul/webserver"
) )
var json = jsoniter.ConfigFastest var json = jsoniter.ConfigFastest
type Manager struct { type Manager struct {
client *Client client *Client
cancelSubs func()
} }
func NewManager(db database.Database, server *webserver.WebServer, logger *zap.Logger) (*Manager, error) { func NewManager(ctx context.Context, db database.Database, server *webserver.WebServer, logger *zap.Logger) (*Manager, error) {
// Get Twitch config // Get Twitch config
var config twitch.Config var config twitch.Config
if err := db.GetJSON(twitch.ConfigKey, &config); err != nil { if err := db.GetJSON(twitch.ConfigKey, &config); err != nil {
@ -37,8 +37,10 @@ func NewManager(db database.Database, server *webserver.WebServer, logger *zap.L
} }
// Create new client // Create new client
client, err := newClient(config, db, server, logger) clientContext, cancel := context.WithCancel(ctx)
client, err := newClient(clientContext, config, db, server, logger)
if err != nil { if err != nil {
cancel()
return nil, fmt.Errorf("failed to create twitch client: %w", err) return nil, fmt.Errorf("failed to create twitch client: %w", err)
} }
@ -47,41 +49,32 @@ func NewManager(db database.Database, server *webserver.WebServer, logger *zap.L
} }
// Listen for client config changes // Listen for client config changes
cancelConfigSub, err := db.SubscribeKey(twitch.ConfigKey, func(value string) { if err = db.SubscribeKeyContext(ctx, twitch.ConfigKey, func(value string) {
var newConfig twitch.Config var newConfig twitch.Config
if err := json.UnmarshalFromString(value, &newConfig); err != nil { if err := json.UnmarshalFromString(value, &newConfig); err != nil {
logger.Error("Failed to decode Twitch integration config", zap.Error(err)) logger.Error("Failed to decode Twitch integration config", zap.Error(err))
return return
} }
cancel()
var updatedClient *Client var updatedClient *Client
updatedClient, err = newClient(newConfig, db, server, logger) clientContext, cancel = context.WithCancel(ctx)
updatedClient, err = newClient(clientContext, newConfig, db, server, logger)
if err != nil { if err != nil {
logger.Error("Could not create twitch client with new config, keeping old", zap.Error(err)) logger.Error("Could not create twitch client with new config, keeping old", zap.Error(err))
return return
} }
err = manager.client.Close()
if err != nil {
logger.Warn("Twitch client could not close cleanly", zap.Error(err))
}
// New client works, replace old // New client works, replace old
updatedClient.Merge(manager.client) updatedClient.Merge(manager.client)
manager.client = updatedClient manager.client = updatedClient
logger.Info("Reloaded/updated Twitch integration") logger.Info("Reloaded/updated Twitch integration")
}) }); err != nil {
if err != nil {
logger.Error("Could not setup twitch config reload subscription", zap.Error(err)) logger.Error("Could not setup twitch config reload subscription", zap.Error(err))
} }
manager.cancelSubs = func() {
if cancelConfigSub != nil {
cancelConfigSub()
}
}
return manager, nil return manager, nil
} }
@ -89,16 +82,6 @@ func (m *Manager) Client() *Client {
return m.client return m.client
} }
func (m *Manager) Close() error {
m.cancelSubs()
if err := m.client.Close(); err != nil {
return err
}
return nil
}
type Client struct { type Client struct {
Config *sync.RWSync[twitch.Config] Config *sync.RWSync[twitch.Config]
DB database.Database DB database.Database
@ -106,6 +89,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
@ -128,9 +115,8 @@ func (c *Client) ensureRoute() {
} }
} }
func newClient(config twitch.Config, db database.Database, server *webserver.WebServer, logger *zap.Logger) (*Client, error) { func newClient(ctx context.Context, config twitch.Config, db database.Database, server *webserver.WebServer, logger *zap.Logger) (*Client, error) {
// Create Twitch client // Create Twitch client
ctx, cancel := context.WithCancel(context.Background())
client := &Client{ client := &Client{
Config: sync.NewRWSync(config), Config: sync.NewRWSync(config),
DB: db, DB: db,
@ -138,39 +124,51 @@ func newClient(config twitch.Config, db database.Database, server *webserver.Web
restart: make(chan bool, 128), restart: make(chan bool, 128),
streamOnline: sync.NewRWSync(false), streamOnline: sync.NewRWSync(false),
ctx: ctx, ctx: ctx,
cancel: cancel,
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()
go func() {
<-ctx.Done()
server.UnregisterRoute(twitch.CallbackRoute)
}()
return client, nil return client, nil
} }
@ -185,7 +183,7 @@ func (c *Client) runStatusPoll() {
// Check if streamer is online, if possible // Check if streamer is online, if possible
func() { func() {
status, err := c.API.GetStreams(&helix.StreamsParams{ status, err := c.API.GetStreams(&helix.StreamsParams{
UserLogins: []string{c.Config.Get().Channel}, // TODO Replace with something non bot dependant UserIDs: []string{c.User.ID},
}) })
if err != nil { if err != nil {
c.Logger.Error("Error checking stream status", zap.Error(err)) c.Logger.Error("Error checking stream status", zap.Error(err))
@ -207,18 +205,3 @@ func (c *Client) runStatusPoll() {
} }
} }
} }
func (c *Client) baseURL() (string, error) {
var severConfig struct {
Bind string `json:"bind"`
}
err := c.DB.GetJSON("http/config", &severConfig)
return severConfig.Bind, err
}
func (c *Client) Close() error {
c.server.UnregisterRoute(twitch.CallbackRoute)
defer c.cancel()
return nil
}

View file

@ -7,15 +7,9 @@ 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"`
// Twitch API App Client Secret // Twitch API App Client Secret
APIClientSecret string `json:"api_client_secret" desc:"Twitch API App Client Secret"` APIClientSecret string `json:"api_client_secret" desc:"Twitch API App Client Secret"`
// Twitch channel to use
Channel string `json:"channel" desc:"Twitch channel to join and use"`
} }

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"
@ -22,16 +23,17 @@ type Module struct {
lastTrigger *sync.Map[string, time.Time] lastTrigger *sync.Map[string, time.Time]
messages *sync.Slice[int] messages *sync.Slice[int]
logger *zap.Logger logger *zap.Logger
db database.Database db database.Database
cancelTimerSub database.CancelFunc ctx context.Context
} }
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,
} }
@ -42,8 +44,7 @@ func Setup(db database.Database, logger *zap.Logger) *Module {
} }
// Load config from database // Load config from database
err := db.GetJSON(ConfigKey, &mod.Config) if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
if err != nil {
logger.Debug("Config load error", zap.Error(err)) logger.Debug("Config load error", zap.Error(err))
mod.Config = Config{ mod.Config = Config{
Timers: make(map[string]ChatTimer), Timers: make(map[string]ChatTimer),
@ -55,15 +56,13 @@ func Setup(db database.Database, logger *zap.Logger) *Module {
} }
} }
mod.cancelTimerSub, err = db.SubscribeKey(ConfigKey, func(value string) { if err := db.SubscribeKeyContext(ctx, ConfigKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config) if err := json.UnmarshalFromString(value, &mod.Config); err != nil {
if err != nil { logger.Warn("Error reloading timer config", zap.Error(err))
logger.Debug("Error reloading timer config", zap.Error(err)) return
} else {
logger.Info("Reloaded timer config")
} }
}) logger.Info("Reloaded timer config")
if err != nil { }); err != nil {
logger.Error("Could not set-up timer reload subscription", zap.Error(err)) logger.Error("Could not set-up timer reload subscription", zap.Error(err))
} }
@ -146,12 +145,6 @@ func (m *Module) ProcessTimer(name string, timer ChatTimer, activity int) {
m.lastTrigger.SetKey(name, now) m.lastTrigger.SetKey(name, now)
} }
func (m *Module) Close() {
if m.cancelTimerSub != nil {
m.cancelTimerSub()
}
}
func (m *Module) currentChatActivity() int { func (m *Module) currentChatActivity() int {
total := 0 total := 0
for _, v := range m.messages.Get() { for _, v := range m.messages.Get() {