1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-10-02 02:50:32 +00:00

Compare commits

..

No commits in common. "a06b9457ea27358a08c2e0cb6f654b3229f06db9" and "0d1c60451b346e142d1f15f84385834f0a15a737" have entirely different histories.

30 changed files with 458 additions and 465 deletions

View file

@ -16,7 +16,6 @@ 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 EventSub handlers for chat functionalities such as custom commands and alerts - Twitch bot for handling chat messages and providing custom commands
- 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,4 +54,6 @@ 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,11 +12,15 @@ 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"
@ -24,9 +28,7 @@ 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"
) )
@ -86,12 +88,6 @@ 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")
@ -152,7 +148,7 @@ func (a *App) initializeComponents() error {
} }
// Create twitch client // Create twitch client
a.twitchManager, err = client.NewManager(a.ctx, a.db, a.httpServer, logger) a.twitchManager, err = client.NewManager(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)
} }
@ -196,6 +192,9 @@ 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,7 +1,6 @@
package database package database
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
@ -25,20 +24,13 @@ 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)
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)
GetJSON(key string, dst any) error GetJSON(key string, dst any) error
GetAll(prefix string) (map[string]string, 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
} }
@ -95,32 +87,6 @@ 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",
"chat-commands": "Chat commands", "bot-commands": "Chat commands",
"chat-timers": "Chat timers", "bot-timers": "Chat timers",
"chat-alerts": "Chat alerts" "bot-alerts": "Chat alerts"
}, },
"loyalty": { "loyalty": {
"configuration": "Configuration", "configuration": "Configuration",
@ -66,16 +66,18 @@
"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 interactions and API access. If you stream on Twitch, you definitely want this on.", "subtitle": "Twitch integration with streams including chat bot 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",
"chat-settings": "Chat settings", "bot-settings": "Bot settings",
"enable-bot": "Enable chat features", "enable-bot": "Enable Twitch bot",
"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": {
@ -105,7 +107,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": "Chat commands", "title": "Bot 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",
@ -133,12 +135,12 @@
"streamer": "Streamer only" "streamer": "Streamer only"
}, },
"remove-command-title": "Remove command {{name}}?", "remove-command-title": "Remove command {{name}}?",
"no-commands": "There are no commands configured", "no-commands": "Chatbot has 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": "Chat timers", "title": "Bot 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",
@ -299,7 +301,7 @@
"landing": "Welcome", "landing": "Welcome",
"twitch-config": "Twitch integration", "twitch-config": "Twitch integration",
"twitch-events": "Twitch events", "twitch-events": "Twitch events",
"twitch-bot": "Twitch chat", "twitch-bot": "Twitch bot",
"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.",
@ -309,7 +311,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 chat integrations).", "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-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": {
"chat-alerts": "Avvisi in chat", "bot-alerts": "Avvisi in chat",
"chat-commands": "Comandi chat", "bot-commands": "Comandi bot",
"chat-timers": "Timer chat", "bot-timers": "Timer bot",
"configuration": "Configurazione" "configuration": "Configurazione"
} }
}, },
@ -332,9 +332,10 @@
"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>",
"chat-settings": "Impostazioni chat", "bot-settings": "Impostazioni bot",
"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,
TwitchChatConfig, TwitchBotConfig,
TwitchConfig, TwitchConfig,
TwitchChatCustomCommands, TwitchBotCustomCommands,
TwitchChatTimersConfig, TwitchBotTimersConfig,
TwitchChatAlertsConfig, TwitchBotAlertsConfig,
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;
}, },
), ),
twitchChatConfig: makeModule( twitchBotConfig: makeModule(
'twitch/chat/config', 'twitch/bot-config',
(state) => state.moduleConfigs?.twitchChatConfig, (state) => state.moduleConfigs?.twitchBotConfig,
(state, { payload }) => { (state, { payload }) => {
state.moduleConfigs.twitchChatConfig = payload as TwitchChatConfig; state.moduleConfigs.twitchBotConfig = payload as TwitchBotConfig;
}, },
), ),
twitchChatCommands: makeModule( twitchBotCommands: makeModule(
'twitch/chat/custom-commands', 'twitch/bot-custom-commands',
(state) => state.twitchChat?.commands, (state) => state.twitchBot?.commands,
(state, { payload }) => { (state, { payload }) => {
state.twitchChat.commands = payload as TwitchChatCustomCommands; state.twitchBot.commands = payload as TwitchBotCustomCommands;
}, },
), ),
twitchChatTimers: makeModule( twitchBotTimers: makeModule(
'twitch/timers/config', 'twitch/bot-modules/timers/config',
(state) => state.twitchChat?.timers, (state) => state.twitchBot?.timers,
(state, { payload }) => { (state, { payload }) => {
state.twitchChat.timers = payload as TwitchChatTimersConfig; state.twitchBot.timers = payload as TwitchBotTimersConfig;
}, },
), ),
twitchChatAlerts: makeModule( twitchBotAlerts: makeModule(
'twitch/alerts/config', 'twitch/bot-modules/alerts/config',
(state) => state.twitchChat?.alerts, (state) => state.twitchBot?.alerts,
(state, { payload }) => { (state, { payload }) => {
state.twitchChat.alerts = payload as TwitchChatAlertsConfig; state.twitchBot.alerts = payload as TwitchBotAlertsConfig;
}, },
), ),
loyaltyConfig: makeModule( loyaltyConfig: makeModule(
@ -269,7 +269,7 @@ const initialState: APIState = {
goals: null, goals: null,
redeemQueue: null, redeemQueue: null,
}, },
twitchChat: { twitchBot: {
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,
twitchChatConfig: null, twitchBotConfig: null,
loyaltyConfig: null, loyaltyConfig: null,
}, },
uiConfig: null, uiConfig: null,

View file

@ -12,11 +12,16 @@ 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 TwitchChatConfig { export interface TwitchBotConfig {
username: string;
oauth: string;
channel: string;
chat_history: number;
command_cooldown: number; command_cooldown: number;
} }
@ -31,7 +36,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 TwitchChatCustomCommand { export interface TwitchBotCustomCommand {
description: string; description: string;
access_level: AccessLevelType; access_level: AccessLevelType;
response: string; response: string;
@ -39,7 +44,7 @@ export interface TwitchChatCustomCommand {
enabled: boolean; enabled: boolean;
} }
export type TwitchChatCustomCommands = Record<string, TwitchChatCustomCommand>; export type TwitchBotCustomCommands = Record<string, TwitchBotCustomCommand>;
export interface LoyaltyConfig { export interface LoyaltyConfig {
enabled: boolean; enabled: boolean;
@ -52,7 +57,7 @@ export interface LoyaltyConfig {
banlist: string[]; banlist: string[];
} }
export interface TwitchChatTimer { export interface TwitchBotTimer {
enabled: boolean; enabled: boolean;
name: string; name: string;
minimum_chat_activity: number; minimum_chat_activity: number;
@ -60,11 +65,11 @@ export interface TwitchChatTimer {
messages: string[]; messages: string[];
} }
export interface TwitchChatTimersConfig { export interface TwitchBotTimersConfig {
timers: Record<string, TwitchChatTimer>; timers: Record<string, TwitchBotTimer>;
} }
export interface TwitchChatAlertsConfig { export interface TwitchBotAlertsConfig {
follow: { follow: {
enabled: boolean; enabled: boolean;
messages: string[]; messages: string[];
@ -171,15 +176,15 @@ export interface APIState {
goals: LoyaltyGoal[]; goals: LoyaltyGoal[];
redeemQueue: LoyaltyRedeem[]; redeemQueue: LoyaltyRedeem[];
}; };
twitchChat: { twitchBot: {
commands: TwitchChatCustomCommands; commands: TwitchBotCustomCommands;
timers: TwitchChatTimersConfig; timers: TwitchBotTimersConfig;
alerts: TwitchChatAlertsConfig; alerts: TwitchBotAlertsConfig;
}; };
moduleConfigs: { moduleConfigs: {
httpConfig: HTTPConfig; httpConfig: HTTPConfig;
twitchConfig: TwitchConfig; twitchConfig: TwitchConfig;
twitchChatConfig: TwitchChatConfig; twitchBotConfig: TwitchBotConfig;
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 TwitchChatCommandsPage from './pages/ChatCommands'; import TwitchBotCommandsPage from './pages/BotCommands';
import TwitchChatTimersPage from './pages/ChatTimers'; import TwitchBotTimersPage from './pages/BotTimers';
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.chat-commands', title: 'menu.pages.twitch.bot-commands',
url: '/twitch/chat/commands', url: '/twitch/bot/commands',
icon: <ChatBubbleIcon />, icon: <ChatBubbleIcon />,
}, },
{ {
title: 'menu.pages.twitch.chat-timers', title: 'menu.pages.twitch.bot-timers',
url: '/twitch/chat/timers', url: '/twitch/bot/timers',
icon: <TimerIcon />, icon: <TimerIcon />,
}, },
{ {
title: 'menu.pages.twitch.chat-alerts', title: 'menu.pages.twitch.bot-alerts',
url: '/twitch/chat/alerts', url: '/twitch/bot/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/chat/commands" path="/twitch/bot/commands"
element={<TwitchChatCommandsPage />} element={<TwitchBotCommandsPage />}
/> />
<Route <Route
path="/twitch/chat/timers" path="/twitch/bot/timers"
element={<TwitchChatTimersPage />} element={<TwitchBotTimersPage />}
/> />
<Route path="/twitch/chat/alerts" element={<ChatAlertsPage />} /> <Route path="/twitch/bot/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

@ -8,7 +8,7 @@ import {
accessLevels, accessLevels,
AccessLevelType, AccessLevelType,
ReplyType, ReplyType,
TwitchChatCustomCommand, TwitchBotCustomCommand,
} 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: TwitchChatCustomCommand; item: TwitchBotCustomCommand;
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: TwitchChatCustomCommand }; | { kind: 'edit'; name: string; item: TwitchBotCustomCommand };
function CommandDialog({ function CommandDialog({
kind, kind,
@ -222,10 +222,10 @@ function CommandDialog({
}: { }: {
kind: 'new' | 'edit'; kind: 'new' | 'edit';
name?: string; name?: string;
item?: TwitchChatCustomCommand; item?: TwitchBotCustomCommand;
onSubmit?: (name: string, item: TwitchChatCustomCommand) => void; onSubmit?: (name: string, item: TwitchBotCustomCommand) => void;
}) { }) {
const [commands] = useModule(modules.twitchChatCommands); const [commands] = useModule(modules.twitchBotCommands);
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 TwitchChatCommandsPage(): React.ReactElement { export default function TwitchBotCommandsPage(): React.ReactElement {
const [commands, setCommands] = useModule(modules.twitchChatCommands); const [commands, setCommands] = useModule(modules.twitchBotCommands);
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 TwitchChatCommandsPage(): React.ReactElement {
const filterLC = filter.toLowerCase(); const filterLC = filter.toLowerCase();
const setCommand = (newName: string, data: TwitchChatCustomCommand): void => { const setCommand = (newName: string, data: TwitchBotCustomCommand): 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 { TwitchChatTimer } from '~/store/api/types'; import { TwitchBotTimer } 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: TwitchChatTimer; item: TwitchBotTimer;
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: TwitchChatTimer }; | { kind: 'edit'; name: string; item: TwitchBotTimer };
function TimerDialog({ function TimerDialog({
kind, kind,
@ -192,10 +192,10 @@ function TimerDialog({
}: { }: {
kind: 'new' | 'edit'; kind: 'new' | 'edit';
name?: string; name?: string;
item?: TwitchChatTimer; item?: TwitchBotTimer;
onSubmit?: (name: string, item: TwitchChatTimer) => void; onSubmit?: (name: string, item: TwitchBotTimer) => void;
}) { }) {
const [timerConfig] = useModule(modules.twitchChatTimers); const [timerConfig] = useModule(modules.twitchBotTimers);
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 TwitchChatTimersPage(): React.ReactElement { export default function TwitchBotTimersPage(): React.ReactElement {
const [timerConfig, setTimerConfig] = useModule(modules.twitchChatTimers); const [timerConfig, setTimerConfig] = useModule(modules.twitchBotTimers);
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 TwitchChatTimersPage(): React.ReactElement {
const filterLC = filter.toLowerCase(); const filterLC = filter.toLowerCase();
const setTimer = (newName: string, data: TwitchChatTimer): void => { const setTimer = (newName: string, data: TwitchBotTimer): void => {
switch (activeDialog.kind) { switch (activeDialog.kind) {
case 'new': case 'new':
void dispatch( void dispatch(

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.twitchChatAlerts); const [alerts, setAlerts, loadStatus] = useModule(modules.twitchBotAlerts);
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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...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.twitchChatAlertsChanged({ apiReducer.actions.twitchBotAlertsChanged({
...alerts, ...alerts,
cheer: { ...alerts.cheer, messages }, cheer: { ...alerts.cheer, messages },
}), }),

View file

@ -158,9 +158,7 @@ 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 let date = data.date ? new Date(data.date) : null;
? new Date(data.date)
: new Date(data.subscription.created_at);
switch (message.type) { switch (message.type) {
case EventSubNotificationType.Followed: { case EventSubNotificationType.Followed: {
content = ( content = (
@ -384,16 +382,10 @@ 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 a.date && b.date ? Date.parse(b.date) - Date.parse(a.date) : 0,
? 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 <TwitchEvent key={`${ev.subscription.id}-${ev.date}`} data={ev} />
key={`${ev.subscription.id}-${ev.subscription.created_at}`}
data={ev}
/>
))} ))}
</EventListContainer> </EventListContainer>
</Scrollbar> </Scrollbar>
@ -441,19 +433,6 @@ 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)
@ -461,7 +440,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));
setCleanTwitchEvents(events); setTwitchEvents(events);
}; };
useEffect(() => { useEffect(() => {
@ -469,7 +448,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 setCleanTwitchEvents((prev) => [event, ...prev]); void setTwitchEvents((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.twitchChatConfig); const [botConfig, setBotConfig] = useModule(modules.twitchBotConfig);
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,6 +19,7 @@ import {
Checkbox, Checkbox,
CheckboxIndicator, CheckboxIndicator,
Field, Field,
FieldNote,
FlexRow, FlexRow,
InputBox, InputBox,
Label, Label,
@ -53,27 +54,143 @@ const Step = styled('li', {
}, },
}); });
function TwitchChatSettings() { function TwitchBotSettings() {
const [chatConfig, setChatConfig, loadStatus] = useModule( const [botConfig, setBotConfig, loadStatus] = useModule(
modules.twitchChatConfig, modules.twitchBotConfig,
); );
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 disabled = status?.type === 'pending'; const [revealBotToken, setRevealBotToken] = useState(false);
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(setChatConfig(chatConfig)); void dispatch(setBotConfig(botConfig));
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')}
@ -81,15 +198,13 @@ function TwitchChatSettings() {
<InputBox <InputBox
type="number" type="number"
id="bot-chat-history" id="bot-chat-history"
required={true} required={active}
disabled={disabled} disabled={disabled}
defaultValue={ defaultValue={botConfig ? botConfig.command_cooldown ?? 2 : undefined}
chatConfig ? chatConfig.command_cooldown ?? 2 : undefined
}
onChange={(ev) => onChange={(ev) =>
dispatch( dispatch(
apiReducer.actions.twitchChatConfigChanged({ apiReducer.actions.twitchBotConfigChanged({
...chatConfig, ...botConfig,
command_cooldown: parseInt(ev.target.value, 10), command_cooldown: parseInt(ev.target.value, 10),
}), }),
) )
@ -417,8 +532,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="chat-settings"> <TabButton value="bot-settings">
{t('pages.twitch-settings.chat-settings')} {t('pages.twitch-settings.bot-settings')}
</TabButton> </TabButton>
</TabList> </TabList>
<TabContent value="api-config"> <TabContent value="api-config">
@ -427,8 +542,8 @@ export default function TwitchSettingsPage(): React.ReactElement {
<TabContent value="eventsub"> <TabContent value="eventsub">
<TwitchEventSubSettings /> <TwitchEventSubSettings />
</TabContent> </TabContent>
<TabContent value="chat-settings"> <TabContent value="bot-settings">
<TwitchChatSettings /> <TwitchBotSettings />
</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.1 github.com/nicklaw5/helix/v2 v2.28.0
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.1 h1:bLVKMrZ0MiSgCLB3nsi7+OrhognsIusqvNL4XFoRG0A= github.com/nicklaw5/helix/v2 v2.28.0 h1:BCpIh9gf/7dsTNyxzgY18VHpt9W6/t0zUioyuDhH6tA=
github.com/nicklaw5/helix/v2 v2.28.1/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY= github.com/nicklaw5/helix/v2 v2.28.0/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=

View file

@ -1,23 +0,0 @@
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)
}

View file

@ -1,72 +0,0 @@
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
}

View file

@ -1,82 +0,0 @@
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,16 +1,17 @@
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
@ -33,19 +34,20 @@ 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(ctx context.Context, db database.Database, logger *zap.Logger, templater template2.Engine) *Module { func Setup(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,
@ -54,7 +56,8 @@ func Setup(ctx context.Context, db database.Database, logger *zap.Logger, templa
} }
// Load config from database // Load config from database
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil { err := db.GetJSON(ConfigKey, &mod.Config)
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
@ -66,7 +69,7 @@ func Setup(ctx context.Context, db database.Database, logger *zap.Logger, templa
mod.compileTemplates() mod.compileTemplates()
if err := db.SubscribeKeyContext(ctx, ConfigKey, func(value string) { mod.cancelAlertSub, err = db.SubscribeKey(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))
@ -74,11 +77,13 @@ func Setup(ctx context.Context, db database.Database, logger *zap.Logger, templa
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))
} }
if err := db.SubscribePrefixContext(ctx, mod.onEventSubEvent, eventsub.EventKeyPrefix); err != nil { mod.cancelTwitchEventSub, err = db.SubscribePrefix(mod.onEventSubEvent, eventsub.EventKeyPrefix)
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))
} }
@ -86,3 +91,12 @@ func Setup(ctx context.Context, db database.Database, logger *zap.Logger, templa
return mod return mod
} }
func (m *Module) Close() {
if m.cancelAlertSub != nil {
m.cancelAlertSub()
}
if m.cancelTwitchEventSub != nil {
m.cancelTwitchEventSub()
}
}

View file

@ -3,6 +3,9 @@ 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,6 +1,8 @@
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,24 +24,30 @@ type Module struct {
Config Config Config Config
ctx context.Context ctx context.Context
db database.Database db *database.LocalDBClient
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.Database, api *helix.Client, user helix.User, logger *zap.Logger, templater template.Engine) *Module { func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, user *helix.User, logger *zap.Logger, templater template.Engine) *Module {
newContext, cancel := context.WithCancel(ctx)
mod := &Module{ mod := &Module{
ctx: ctx, ctx: newContext,
db: db, db: db,
api: api, api: api,
user: user, user: user,
@ -53,12 +59,15 @@ func Setup(ctx context.Context, db database.Database, api *helix.Client, user he
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 {
@ -66,13 +75,16 @@ func Setup(ctx context.Context, db database.Database, api *helix.Client, user he
} }
} }
if err := db.SubscribeKeyContext(ctx, eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage); err != nil { var err error
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
if err := db.GetJSON(CustomCommandsKey, &customCommands); err != nil { err = db.GetJSON(CustomCommandsKey, &customCommands)
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 {
@ -81,33 +93,48 @@ func Setup(ctx context.Context, db database.Database, api *helix.Client, user he
} }
mod.customCommands.Set(customCommands) mod.customCommands.Set(customCommands)
if err := mod.updateTemplates(); err != nil { err = mod.updateTemplates()
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 := db.SubscribeKeyContext(ctx, CustomCommandsKey, mod.updateCommands); err != nil { if 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 := db.SubscribeKeyContext(ctx, WriteMessageRPC, mod.handleWriteMessageRPC); err != nil { if 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) onChatMessage(newValue string) { func (mod *Module) Close() {
var chatMessage struct { if mod.cancelChatMessageSub != nil {
Event helix.EventSubChannelChatMessageEvent `json:"event"` mod.cancelChatMessageSub()
} }
if err := json.UnmarshalFromString(newValue, &chatMessage); err != nil {
if mod.cancelUpdateSub != nil {
mod.cancelUpdateSub()
}
if mod.cancelWriteRPCSub != nil {
mod.cancelWriteRPCSub()
}
mod.cancelContext()
}
func (mod *Module) onChatMessage(newValue string) {
var chatMessage helix.EventSubChannelChatMessageEvent
if err := json.Unmarshal([]byte(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.Event.Message.Text)) lowercaseMessage := strings.TrimSpace(strings.ToLower(chatMessage.Message.Text))
// Check if it's a command // Check if it's a command
if strings.HasPrefix(lowercaseMessage, "!") { if strings.HasPrefix(lowercaseMessage, "!") {
@ -123,7 +150,7 @@ func (mod *Module) onChatMessage(newValue string) {
if parts[0] != cmd { if parts[0] != cmd {
continue continue
} }
go data.Handler(chatMessage.Event) go data.Handler(chatMessage)
mod.lastMessage.Set(time.Now()) mod.lastMessage.Set(time.Now())
} }
} }
@ -141,9 +168,25 @@ func (mod *Module) onChatMessage(newValue string) {
if parts[0] != lc { if parts[0] != lc {
continue continue
} }
go cmdCustom(mod, cmd, data, chatMessage.Event) go cmdCustom(mod, cmd, data, chatMessage)
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(ctx context.Context, db database.Database, server *webserver.WebServer, logger *zap.Logger) (*Manager, error) { func NewManager(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,10 +37,8 @@ func NewManager(ctx context.Context, db database.Database, server *webserver.Web
} }
// Create new client // Create new client
clientContext, cancel := context.WithCancel(ctx) client, err := newClient(config, db, server, logger)
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)
} }
@ -49,32 +47,41 @@ func NewManager(ctx context.Context, db database.Database, server *webserver.Web
} }
// Listen for client config changes // Listen for client config changes
if err = db.SubscribeKeyContext(ctx, twitch.ConfigKey, func(value string) { cancelConfigSub, err := db.SubscribeKey(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
clientContext, cancel = context.WithCancel(ctx) updatedClient, err = newClient(newConfig, db, server, logger)
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
} }
@ -82,6 +89,16 @@ 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
@ -89,10 +106,6 @@ 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
@ -115,8 +128,9 @@ func (c *Client) ensureRoute() {
} }
} }
func newClient(ctx context.Context, config twitch.Config, db database.Database, server *webserver.WebServer, logger *zap.Logger) (*Client, error) { func newClient(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,
@ -124,51 +138,39 @@ func newClient(ctx context.Context, config twitch.Config, db database.Database,
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 {
return client, nil var err error
} client.API, err = twitch.GetHelixAPI(db)
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 { if err != nil {
client.Logger.Error("Failed looking up user", zap.Error(err)) return nil, fmt.Errorf("failed to create twitch client: %w", 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") 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()
} }
go client.runStatusPoll()
go func() {
<-ctx.Done()
server.UnregisterRoute(twitch.CallbackRoute)
}()
return client, nil return client, nil
} }
@ -183,7 +185,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{
UserIDs: []string{c.User.ID}, UserLogins: []string{c.Config.Get().Channel}, // TODO Replace with something non bot dependant
}) })
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))
@ -205,3 +207,18 @@ 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,9 +7,15 @@ 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,6 +32,16 @@ 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,6 +183,7 @@ 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,7 +25,6 @@ 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,7 +1,6 @@
package timers package timers
import ( import (
"context"
"math/rand" "math/rand"
"time" "time"
@ -23,17 +22,16 @@ 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
ctx context.Context cancelTimerSub database.CancelFunc
} }
func Setup(ctx context.Context, db database.Database, logger *zap.Logger) *Module { func Setup(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,
} }
@ -44,7 +42,8 @@ func Setup(ctx context.Context, db database.Database, logger *zap.Logger) *Modul
} }
// Load config from database // Load config from database
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil { err := db.GetJSON(ConfigKey, &mod.Config)
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),
@ -56,13 +55,15 @@ func Setup(ctx context.Context, db database.Database, logger *zap.Logger) *Modul
} }
} }
if err := db.SubscribeKeyContext(ctx, ConfigKey, func(value string) { mod.cancelTimerSub, err = db.SubscribeKey(ConfigKey, func(value string) {
if err := json.UnmarshalFromString(value, &mod.Config); err != nil { err := json.UnmarshalFromString(value, &mod.Config)
logger.Warn("Error reloading timer config", zap.Error(err)) if err != nil {
return logger.Debug("Error reloading timer config", zap.Error(err))
} else {
logger.Info("Reloaded timer config")
} }
logger.Info("Reloaded timer config") })
}); err != nil { if 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))
} }
@ -145,6 +146,12 @@ 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() {