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

View file

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

21
app.go
View file

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

View file

@ -1,6 +1,7 @@
package database
import (
"context"
"errors"
"fmt"
@ -24,13 +25,20 @@ var (
type Database interface {
GetKey(key string) (string, error)
PutKey(key string, data string) error
RemoveKey(key string) error
SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...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)
GetJSON(key string, dst any) error
PutJSON(key string, data any) error
PutJSONBulk(kvs map[string]any) error
RemoveKey(key string) error
Hub() *kv.Hub
}
@ -87,6 +95,32 @@ func (mod *LocalDBClient) PutKey(key string, data string) error {
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) {
var ids []int64
for _, prefix := range prefixes {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -158,7 +158,9 @@ function TwitchEvent({ data }: { data: EventSubNotification }) {
let content: JSX.Element | string;
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) {
case EventSubNotificationType.Followed: {
content = (
@ -382,10 +384,16 @@ function TwitchEventLog({ events }: { events: EventSubNotification[] }) {
{events
.filter((ev) => supportedMessages.includes(ev.subscription.type))
.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) => (
<TwitchEvent key={`${ev.subscription.id}-${ev.date}`} data={ev} />
<TwitchEvent
key={`${ev.subscription.id}-${ev.subscription.created_at}`}
data={ev}
/>
))}
</EventListContainer>
</Scrollbar>
@ -433,6 +441,19 @@ function TwitchSection() {
const kv = useAppSelector((state) => state.api.client);
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 keymap = await kv.getKeysByPrefix('twitch/eventsub-history/');
const events = Object.values(keymap)
@ -440,7 +461,7 @@ function TwitchSection() {
.flat()
.sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
setTwitchEvents(events);
setCleanTwitchEvents(events);
};
useEffect(() => {
@ -448,7 +469,7 @@ function TwitchSection() {
const onKeyChange = (value: string) => {
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);

View file

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

View file

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

2
go.mod
View file

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

4
go.sum
View file

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

23
migrations/common.go Normal file
View file

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

72
migrations/migration.go Normal file
View file

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

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
import (
"context"
"sync"
"text/template"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
template2 "git.sr.ht/~ashkeel/strimertul/twitch/template"
jsoniter "github.com/json-iterator/go"
"go.uber.org/zap"
"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
@ -34,20 +33,19 @@ const (
type Module struct {
Config Config
ctx context.Context
db database.Database
logger *zap.Logger
templater template2.Engine
templates templateCacheMap
cancelAlertSub database.CancelFunc
cancelTwitchEventSub database.CancelFunc
pendingMux sync.Mutex
pendingSubs map[string]subMixedEvent
}
func Setup(db database.Database, logger *zap.Logger, templater template2.Engine) *Module {
func Setup(ctx context.Context, db database.Database, logger *zap.Logger, templater template2.Engine) *Module {
mod := &Module{
ctx: ctx,
db: db,
logger: logger,
templater: templater,
@ -56,8 +54,7 @@ func Setup(db database.Database, logger *zap.Logger, templater template2.Engine)
}
// Load config from database
err := db.GetJSON(ConfigKey, &mod.Config)
if err != nil {
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
logger.Debug("Config load error", zap.Error(err))
mod.Config = Config{}
// Save empty config
@ -69,7 +66,7 @@ func Setup(db database.Database, logger *zap.Logger, templater template2.Engine)
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)
if err != nil {
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")
}
mod.compileTemplates()
})
if err != nil {
}); err != nil {
logger.Error("Could not set-up bot alert reload subscription", zap.Error(err))
}
mod.cancelTwitchEventSub, err = db.SubscribePrefix(mod.onEventSubEvent, eventsub.EventKeyPrefix)
if err != nil {
if err := db.SubscribePrefixContext(ctx, mod.onEventSubEvent, eventsub.EventKeyPrefix); err != nil {
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
}
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"
type Config struct {
// How many messages to keep in twitch/chat-history
ChatHistory int `json:"chat_history" desc:"How many messages to keep in the chat history key"`
// Global command cooldown in seconds
CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"`
}

View file

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

View file

@ -24,13 +24,12 @@ type Module struct {
Config Config
ctx context.Context
db *database.LocalDBClient
db database.Database
api *helix.Client
user *helix.User
user helix.User
logger *zap.Logger
templater template.Engine
lastMessage *sync.RWSync[time.Time]
chatHistory *sync.Slice[helix.EventSubChannelChatMessageEvent]
commands *sync.Map[string, Command]
customCommands *sync.Map[string, CustomCommand]
@ -38,16 +37,11 @@ type Module struct {
customFunctions textTemplate.FuncMap
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 {
newContext, cancel := context.WithCancel(ctx)
func Setup(ctx context.Context, db database.Database, api *helix.Client, user helix.User, logger *zap.Logger, templater template.Engine) *Module {
mod := &Module{
ctx: newContext,
ctx: ctx,
db: db,
api: api,
user: user,
@ -59,15 +53,12 @@ func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, u
customCommands: sync.NewMap[string, CustomCommand](),
customTemplates: sync.NewMap[string, *textTemplate.Template](),
customFunctions: make(textTemplate.FuncMap),
cancelContext: cancel,
}
// Get config
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
if errors.Is(err, database.ErrEmptyKey) {
mod.Config = Config{
ChatHistory: 0,
CommandCooldown: 2,
}
} else {
@ -75,16 +66,13 @@ func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, u
}
}
var err error
mod.cancelChatMessageSub, err = db.SubscribeKey(eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage)
if err != nil {
if err := db.SubscribeKeyContext(ctx, eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage); err != nil {
logger.Error("Could not subscribe to chat messages", zap.Error(err))
}
// Load custom commands
var customCommands map[string]CustomCommand
err = db.GetJSON(CustomCommandsKey, &customCommands)
if err != nil {
if err := db.GetJSON(CustomCommandsKey, &customCommands); err != nil {
if errors.Is(err, database.ErrEmptyKey) {
customCommands = make(map[string]CustomCommand)
} else {
@ -93,48 +81,33 @@ func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, u
}
mod.customCommands.Set(customCommands)
err = mod.updateTemplates()
if err != nil {
if err := mod.updateTemplates(); err != nil {
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))
}
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))
}
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) {
var chatMessage helix.EventSubChannelChatMessageEvent
if err := json.Unmarshal([]byte(newValue), &chatMessage); err != nil {
var chatMessage struct {
Event helix.EventSubChannelChatMessageEvent `json:"event"`
}
if err := json.UnmarshalFromString(newValue, &chatMessage); err != nil {
mod.logger.Error("Failed to decode incoming chat message", zap.Error(err))
return
}
// TODO Command cooldown logic here!
lowercaseMessage := strings.TrimSpace(strings.ToLower(chatMessage.Message.Text))
lowercaseMessage := strings.TrimSpace(strings.ToLower(chatMessage.Event.Message.Text))
// Check if it's a command
if strings.HasPrefix(lowercaseMessage, "!") {
@ -150,7 +123,7 @@ func (mod *Module) onChatMessage(newValue string) {
if parts[0] != cmd {
continue
}
go data.Handler(chatMessage)
go data.Handler(chatMessage.Event)
mod.lastMessage.Set(time.Now())
}
}
@ -168,25 +141,9 @@ func (mod *Module) onChatMessage(newValue string) {
if parts[0] != lc {
continue
}
go cmdCustom(mod, cmd, data, chatMessage)
go cmdCustom(mod, cmd, data, chatMessage.Event)
mod.lastMessage.Set(time.Now())
}
err := mod.db.PutJSON(EventKey, chatMessage)
if err != nil {
mod.logger.Warn("Could not save chat message to key", zap.Error(err))
}
if mod.Config.ChatHistory > 0 {
history := mod.chatHistory.Get()
if len(history) >= mod.Config.ChatHistory {
history = history[len(history)-mod.Config.ChatHistory+1:]
}
mod.chatHistory.Set(append(history, chatMessage))
err = mod.db.PutJSON(HistoryKey, mod.chatHistory.Get())
if err != nil {
mod.logger.Warn("Could not save message to chat history", zap.Error(err))
}
}
}
func (mod *Module) handleWriteMessageRPC(value string) {

View file

@ -6,16 +6,17 @@ import (
"fmt"
"time"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/containers/sync"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"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"
)
@ -23,10 +24,9 @@ var json = jsoniter.ConfigFastest
type Manager struct {
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
var config twitch.Config
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
client, err := newClient(config, db, server, logger)
clientContext, cancel := context.WithCancel(ctx)
client, err := newClient(clientContext, config, db, server, logger)
if err != nil {
cancel()
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
cancelConfigSub, err := db.SubscribeKey(twitch.ConfigKey, func(value string) {
if err = db.SubscribeKeyContext(ctx, twitch.ConfigKey, func(value string) {
var newConfig twitch.Config
if err := json.UnmarshalFromString(value, &newConfig); err != nil {
logger.Error("Failed to decode Twitch integration config", zap.Error(err))
return
}
cancel()
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 {
logger.Error("Could not create twitch client with new config, keeping old", zap.Error(err))
return
}
err = manager.client.Close()
if err != nil {
logger.Warn("Twitch client could not close cleanly", zap.Error(err))
}
// New client works, replace old
updatedClient.Merge(manager.client)
manager.client = updatedClient
logger.Info("Reloaded/updated Twitch integration")
})
if err != nil {
}); err != nil {
logger.Error("Could not setup twitch config reload subscription", zap.Error(err))
}
manager.cancelSubs = func() {
if cancelConfigSub != nil {
cancelConfigSub()
}
}
return manager, nil
}
@ -89,16 +82,6 @@ func (m *Manager) Client() *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 {
Config *sync.RWSync[twitch.Config]
DB database.Database
@ -106,6 +89,10 @@ type Client struct {
User helix.User
Logger *zap.Logger
Chat *chat.Module
Alerts *alerts.Module
Timers *timers.Module
eventSub *eventsub.Client
server *webserver.WebServer
ctx context.Context
@ -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
ctx, cancel := context.WithCancel(context.Background())
client := &Client{
Config: sync.NewRWSync(config),
DB: db,
@ -138,11 +124,13 @@ func newClient(config twitch.Config, db database.Database, server *webserver.Web
restart: make(chan bool, 128),
streamOnline: sync.NewRWSync(false),
ctx: ctx,
cancel: cancel,
server: server,
}
if config.Enabled {
if !config.Enabled {
return client, nil
}
var err error
client.API, err = twitch.GetHelixAPI(db)
if err != nil {
@ -158,18 +146,28 @@ func newClient(config twitch.Config, db database.Database, server *webserver.Web
} 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
}
@ -185,7 +183,7 @@ func (c *Client) runStatusPoll() {
// Check if streamer is online, if possible
func() {
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 {
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
Enabled bool `json:"enabled" desc:"Enable subsystem"`
// Enable the chatbot
EnableBot bool `json:"enable_bot" desc:"Enable the chatbot"`
// Twitch API App Client ID
APIClientID string `json:"api_client_id" desc:"Twitch API App Client ID"`
// 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",
Type: reflect.TypeOf(chat.Config{}),
},
chat.EventKey: interfaces.KeyDef{
Description: "On chat message received",
Type: reflect.TypeOf(helix.EventSubChannelChatMessageEvent{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
chat.HistoryKey: interfaces.KeyDef{
Description: "Last chat messages received",
Type: reflect.TypeOf([]helix.EventSubChannelChatMessageEvent{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
chat.ActivityKey: interfaces.KeyDef{
Description: "Number of chat messages in the last minute",
Type: reflect.TypeOf(0),

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package timers
import (
"context"
"math/rand"
"time"
@ -24,14 +25,15 @@ type Module struct {
logger *zap.Logger
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{
lastTrigger: sync.NewMap[string, time.Time](),
messages: sync.NewSlice[int](),
db: db,
ctx: ctx,
logger: logger,
}
@ -42,8 +44,7 @@ func Setup(db database.Database, logger *zap.Logger) *Module {
}
// Load config from database
err := db.GetJSON(ConfigKey, &mod.Config)
if err != nil {
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
logger.Debug("Config load error", zap.Error(err))
mod.Config = Config{
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) {
err := json.UnmarshalFromString(value, &mod.Config)
if err != nil {
logger.Debug("Error reloading timer config", zap.Error(err))
} else {
logger.Info("Reloaded timer config")
if err := db.SubscribeKeyContext(ctx, ConfigKey, func(value string) {
if err := json.UnmarshalFromString(value, &mod.Config); err != nil {
logger.Warn("Error reloading timer config", zap.Error(err))
return
}
})
if err != nil {
logger.Info("Reloaded timer config")
}); err != nil {
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)
}
func (m *Module) Close() {
if m.cancelTimerSub != nil {
m.cancelTimerSub()
}
}
func (m *Module) currentChatActivity() int {
total := 0
for _, v := range m.messages.Get() {