mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-30 02:40:33 +00:00
Compare commits
No commits in common. "0d1c60451b346e142d1f15f84385834f0a15a737" and "3c3ea7bdb40e58f299be8d7bae7aae187386b6a0" have entirely different histories.
0d1c60451b
...
3c3ea7bdb4
47 changed files with 2252 additions and 2511 deletions
|
@ -10,12 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
|
||||
- The windows can now shrink no more than 480x300 but the UI will now better adapt to small window sizes
|
||||
- A new part of the dashboard will now inform the user if any configuration problems have been detected.
|
||||
|
||||
### Changed
|
||||
|
||||
- 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.
|
||||
|
||||
## 3.3.1 - 2023-11-12
|
||||
|
||||
|
|
12
app.go
12
app.go
|
@ -12,8 +12,6 @@ import (
|
|||
"runtime/debug"
|
||||
"strconv"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch/client"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
|
||||
kv "github.com/strimertul/kilovolt/v11"
|
||||
|
@ -43,7 +41,7 @@ type App struct {
|
|||
cancelLogs database.CancelFunc
|
||||
|
||||
db *database.LocalDBClient
|
||||
twitchManager *client.Manager
|
||||
twitchManager *twitch.Manager
|
||||
httpServer *webserver.WebServer
|
||||
loyaltyManager *loyalty.Manager
|
||||
}
|
||||
|
@ -148,13 +146,13 @@ func (a *App) initializeComponents() error {
|
|||
}
|
||||
|
||||
// Create twitch client
|
||||
a.twitchManager, err = client.NewManager(a.db, a.httpServer, logger)
|
||||
a.twitchManager, err = twitch.NewManager(a.db, a.httpServer, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not initialize twitch client: %w", err)
|
||||
}
|
||||
|
||||
// Initialize loyalty system
|
||||
a.loyaltyManager, err = loyalty.NewManager(a.db, logger)
|
||||
a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchManager, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not initialize loyalty manager: %w", err)
|
||||
}
|
||||
|
@ -227,7 +225,7 @@ func (a *App) GetKilovoltBind() string {
|
|||
}
|
||||
|
||||
func (a *App) GetTwitchAuthURL() string {
|
||||
return twitch.GetAuthorizationURL(a.twitchManager.Client().API)
|
||||
return a.twitchManager.Client().GetAuthorizationURL()
|
||||
}
|
||||
|
||||
func (a *App) GetTwitchLoggedUser() (helix.User, error) {
|
||||
|
@ -298,7 +296,7 @@ func (a *App) GetAppVersion() VersionInfo {
|
|||
}
|
||||
|
||||
func (a *App) TestTemplate(message string, data any) error {
|
||||
tpl, err := a.twitchManager.Client().GetTemplateEngine().MakeTemplate(message)
|
||||
tpl, err := a.twitchManager.Client().Bot.MakeTemplate(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -21,19 +21,6 @@ var (
|
|||
ErrEmptyKey = errors.New("empty key")
|
||||
)
|
||||
|
||||
type Database interface {
|
||||
GetKey(key string) (string, error)
|
||||
PutKey(key string, data 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
|
||||
GetAll(prefix string) (map[string]string, error)
|
||||
PutJSON(key string, data any) error
|
||||
PutJSONBulk(kvs map[string]any) error
|
||||
RemoveKey(key string) error
|
||||
Hub() *kv.Hub
|
||||
}
|
||||
|
||||
type LocalDBClient struct {
|
||||
client *kv.LocalClient
|
||||
hub *kv.Hub
|
||||
|
|
|
@ -3,7 +3,7 @@ package docs
|
|||
import (
|
||||
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
|
||||
"git.sr.ht/~ashkeel/strimertul/loyalty"
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch/doc"
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch"
|
||||
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||
"git.sr.ht/~ashkeel/strimertul/webserver"
|
||||
)
|
||||
|
@ -25,12 +25,12 @@ func addKeys(keyMap interfaces.KeyMap) {
|
|||
|
||||
func init() {
|
||||
// Put all enums here
|
||||
utils.MergeMap(Enums, doc.Enums)
|
||||
utils.MergeMap(Enums, twitch.Enums)
|
||||
utils.MergeMap(Enums, enums)
|
||||
|
||||
// Put all keys here
|
||||
addKeys(strimertulKeys)
|
||||
addKeys(doc.Keys)
|
||||
addKeys(twitch.Keys)
|
||||
addKeys(loyalty.Keys)
|
||||
addKeys(webserver.Keys)
|
||||
}
|
||||
|
|
|
@ -286,10 +286,7 @@
|
|||
},
|
||||
"quick-links": "Useful links",
|
||||
"link-user-guide": "User guide",
|
||||
"link-api": "API reference",
|
||||
"problems": {
|
||||
"eventsub-scope": "{{APPNAME}} needs new permissions in your Twitch app to work correctly.<br/> Click <a>here</a> to re-authenticate."
|
||||
}
|
||||
"link-api": "API reference"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome-header": "Welcome to {{APPNAME}}",
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
CircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InfoCircledIcon,
|
||||
UpdateIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { CircleIcon, InfoCircledIcon, UpdateIcon } from '@radix-ui/react-icons';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import {
|
||||
EventSubNotification,
|
||||
|
@ -14,16 +9,11 @@ import { useLiveKey, useModule } from '~/lib/react';
|
|||
import { useAppDispatch, useAppSelector } from '~/store';
|
||||
import { modules } from '~/store/api/reducer';
|
||||
import * as HoverCard from '@radix-ui/react-hover-card';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { main } from '@wailsapp/go/models';
|
||||
import { GetProblems, GetTwitchAuthURL } from '@wailsapp/go/main/App';
|
||||
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
|
||||
import {
|
||||
PageContainer,
|
||||
SectionHeader,
|
||||
styled,
|
||||
TextBlock,
|
||||
theme,
|
||||
TooltipContent,
|
||||
} from '../theme';
|
||||
import BrowserLink from '../components/BrowserLink';
|
||||
|
@ -147,7 +137,7 @@ function TwitchEvent({ data }: { data: EventSubNotification }) {
|
|||
const client = useAppSelector((state) => state.api.client);
|
||||
|
||||
const replay = () => {
|
||||
void client.putJSON(`twitch/ev/eventsub-event/${data.subscription.type}`, {
|
||||
void client.putJSON('twitch/ev/eventsub-event', {
|
||||
...data,
|
||||
subscription: {
|
||||
...data.subscription,
|
||||
|
@ -430,33 +420,10 @@ function TwitchStreamStatus({ info }: { info: StreamInfo }) {
|
|||
function TwitchSection() {
|
||||
const { t } = useTranslation();
|
||||
const twitchInfo = useLiveKey<StreamInfo[]>('twitch/stream-info');
|
||||
const kv = useAppSelector((state) => state.api.client);
|
||||
const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]);
|
||||
|
||||
const loadRecentEvents = async () => {
|
||||
const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/');
|
||||
const events = Object.values(keymap)
|
||||
.map((value) => JSON.parse(value) as EventSubNotification[])
|
||||
.flat()
|
||||
.sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
|
||||
|
||||
setTwitchEvents(events);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecentEvents();
|
||||
|
||||
const onKeyChange = (value: string) => {
|
||||
const event = JSON.parse(value) as EventSubNotification;
|
||||
void setTwitchEvents((prev) => [event, ...prev]);
|
||||
};
|
||||
|
||||
void kv.subscribePrefix('twitch/ev/eventsub-event/', onKeyChange);
|
||||
|
||||
return () => {
|
||||
void kv.unsubscribePrefix('twitch/ev/eventsub-event/', onKeyChange);
|
||||
};
|
||||
}, []);
|
||||
// const twitchActivity = useLiveKey<StreamInfo[]>('twitch/chat-activity');
|
||||
const twitchEvents = useLiveKey<EventSubNotification[]>(
|
||||
'twitch/eventsub-history',
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -473,88 +440,8 @@ function TwitchSection() {
|
|||
);
|
||||
}
|
||||
|
||||
const ProblemBlock = styled('div', {
|
||||
border: '2px solid $gray6',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: theme.borderRadius.toolbar,
|
||||
variants: {
|
||||
severity: {
|
||||
warn: {
|
||||
borderColor: '$yellow6',
|
||||
backgroundColor: '$yellow3',
|
||||
color: '$yellow12',
|
||||
svg: {
|
||||
color: '$yellow11',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
lineHeight: '1.4',
|
||||
svg: {
|
||||
marginTop: '0.25rem',
|
||||
},
|
||||
a: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
});
|
||||
|
||||
function ProblemList() {
|
||||
const [problems, setProblems] = useState<main.Problem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const kv = useAppSelector((state) => state.api.client);
|
||||
|
||||
useEffect(() => {
|
||||
void GetProblems().then(setProblems);
|
||||
}, []);
|
||||
|
||||
const reauthenticate = async () => {
|
||||
// Wait for re-auth so we can clear the banner
|
||||
const onKeyChange = () => {
|
||||
void GetProblems().then(setProblems);
|
||||
void kv.unsubscribeKey('twitch/auth-keys', onKeyChange);
|
||||
};
|
||||
void kv.subscribeKey('twitch/auth-keys', onKeyChange);
|
||||
|
||||
const url = await GetTwitchAuthURL();
|
||||
BrowserOpenURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{problems.map((p) => {
|
||||
switch (p.id) {
|
||||
case 'twitch:eventsub_scope':
|
||||
return (
|
||||
<ProblemBlock severity="warn">
|
||||
<ExclamationTriangleIcon
|
||||
style={{ width: 'auto', minWidth: '40px', height: '40px' }}
|
||||
/>
|
||||
<header>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey={'pages.dashboard.problems.eventsub-scope'}
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
onClick={() => {
|
||||
void reauthenticate();
|
||||
}}
|
||||
></a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</header>
|
||||
</ProblemBlock>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export default function Dashboard(): React.ReactElement {
|
||||
|
|
|
@ -412,13 +412,12 @@ function TwitchEventSubSettings() {
|
|||
|
||||
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
|
||||
const data = eventsubTests[event];
|
||||
await kv.putJSON(`twitch/ev/eventsub-event/${event}`, {
|
||||
await kv.putJSON('twitch/ev/eventsub-event', {
|
||||
...data,
|
||||
subscription: {
|
||||
...data.subscription,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
date: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
2
frontend/wailsjs/go/main/App.d.ts
vendored
2
frontend/wailsjs/go/main/App.d.ts
vendored
|
@ -16,8 +16,6 @@ export function GetKilovoltBind():Promise<string>;
|
|||
|
||||
export function GetLastLogs():Promise<Array<main.LogEntry>>;
|
||||
|
||||
export function GetProblems():Promise<Array<main.Problem>>;
|
||||
|
||||
export function GetTwitchAuthURL():Promise<string>;
|
||||
|
||||
export function GetTwitchLoggedUser():Promise<helix.User>;
|
||||
|
|
|
@ -26,10 +26,6 @@ export function GetLastLogs() {
|
|||
return window['go']['main']['App']['GetLastLogs']();
|
||||
}
|
||||
|
||||
export function GetProblems() {
|
||||
return window['go']['main']['App']['GetProblems']();
|
||||
}
|
||||
|
||||
export function GetTwitchAuthURL() {
|
||||
return window['go']['main']['App']['GetTwitchAuthURL']();
|
||||
}
|
||||
|
|
|
@ -92,20 +92,6 @@ export namespace main {
|
|||
this.data = source["data"];
|
||||
}
|
||||
}
|
||||
export class Problem {
|
||||
id: string;
|
||||
details: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Problem(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.details = source["details"];
|
||||
}
|
||||
}
|
||||
export class VersionInfo {
|
||||
release: string;
|
||||
// Go type: debug
|
||||
|
|
3
go.mod
3
go.mod
|
@ -10,10 +10,11 @@ require (
|
|||
github.com/Masterminds/sprig/v3 v3.2.3
|
||||
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29
|
||||
github.com/cockroachdb/pebble v1.1.0
|
||||
github.com/gempir/go-twitch-irc/v4 v4.0.0
|
||||
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.26.0
|
||||
github.com/strimertul/kilovolt/v11 v11.0.1
|
||||
github.com/urfave/cli/v2 v2.27.1
|
||||
github.com/wailsapp/wails/v2 v2.8.0
|
||||
|
|
6
go.sum
6
go.sum
|
@ -92,6 +92,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
|
|||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gempir/go-twitch-irc/v4 v4.0.0 h1:sHVIvbWOv9nHXGEErilclxASv0AaQEr/r/f9C0B9aO8=
|
||||
github.com/gempir/go-twitch-irc/v4 v4.0.0/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/jDrXBNbuePfTauzg=
|
||||
github.com/getsentry/sentry-go v0.18.0 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0=
|
||||
github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
|
@ -251,8 +253,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.26.0 h1:Qkc/R0eCDdWtUmnczk2g03+mObPUfc49Kz2Bt4B5d0g=
|
||||
github.com/nicklaw5/helix/v2 v2.26.0/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=
|
||||
|
|
|
@ -7,12 +7,13 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch"
|
||||
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||
|
||||
"git.sr.ht/~ashkeel/containers/sync"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||
)
|
||||
|
||||
var json = jsoniter.ConfigFastest
|
||||
|
@ -30,17 +31,19 @@ type Manager struct {
|
|||
Rewards *sync.Slice[Reward]
|
||||
Goals *sync.Slice[Goal]
|
||||
Queue *sync.Slice[Redeem]
|
||||
db database.Database
|
||||
db *database.LocalDBClient
|
||||
logger *zap.Logger
|
||||
cooldowns map[string]time.Time
|
||||
banlist map[string]bool
|
||||
activeUsers *sync.Map[string, bool]
|
||||
twitchManager *twitch.Manager
|
||||
ctx context.Context
|
||||
cancelFn context.CancelFunc
|
||||
cancelSub database.CancelFunc
|
||||
restartTwitchHandler chan struct{}
|
||||
}
|
||||
|
||||
func NewManager(db database.Database, logger *zap.Logger) (*Manager, error) {
|
||||
func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logger *zap.Logger) (*Manager, error) {
|
||||
ctx, cancelFn := context.WithCancel(context.Background())
|
||||
loyalty := &Manager{
|
||||
Config: sync.NewRWSync(Config{Enabled: false}),
|
||||
|
@ -53,6 +56,8 @@ func NewManager(db database.Database, logger *zap.Logger) (*Manager, error) {
|
|||
points: sync.NewMap[string, PointsEntry](),
|
||||
cooldowns: make(map[string]time.Time),
|
||||
banlist: make(map[string]bool),
|
||||
activeUsers: sync.NewMap[string, bool](),
|
||||
twitchManager: twitchManager,
|
||||
ctx: ctx,
|
||||
cancelFn: cancelFn,
|
||||
restartTwitchHandler: make(chan struct{}),
|
||||
|
@ -122,6 +127,9 @@ func NewManager(db database.Database, logger *zap.Logger) (*Manager, error) {
|
|||
|
||||
loyalty.SetBanList(config.BanList)
|
||||
|
||||
// Setup twitch integration
|
||||
loyalty.SetupTwitch()
|
||||
|
||||
return loyalty, nil
|
||||
}
|
||||
|
||||
|
@ -134,6 +142,9 @@ func (m *Manager) Close() error {
|
|||
// Send cancellation
|
||||
m.cancelFn()
|
||||
|
||||
// Teardown twitch integration
|
||||
m.StopTwitch()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -146,6 +157,8 @@ func (m *Manager) update(key, value string) {
|
|||
if err == nil {
|
||||
m.SetBanList(m.Config.Get().BanList)
|
||||
m.restartTwitchHandler <- struct{}{}
|
||||
m.StopTwitch()
|
||||
m.SetupTwitch()
|
||||
}
|
||||
case GoalsKey:
|
||||
err = utils.LoadJSONToWrapped[[]Goal](value, m.Goals)
|
||||
|
@ -355,15 +368,3 @@ func (m *Manager) Equals(c utils.Comparable) bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) SetBanList(banned []string) {
|
||||
m.banlist = make(map[string]bool)
|
||||
for _, usr := range banned {
|
||||
m.banlist[usr] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) IsBanned(user string) bool {
|
||||
banned, ok := m.banlist[user]
|
||||
return ok && banned
|
||||
}
|
||||
|
|
360
loyalty/twitch-bot.go
Normal file
360
loyalty/twitch-bot.go
Normal file
|
@ -0,0 +1,360 @@
|
|||
package loyalty
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch"
|
||||
|
||||
"git.sr.ht/~ashkeel/containers/sync"
|
||||
irc "github.com/gempir/go-twitch-irc/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
commandRedeem = "!redeem"
|
||||
commandGoals = "!goals"
|
||||
commandBalance = "!balance"
|
||||
commandContribute = "!contribute"
|
||||
)
|
||||
|
||||
func (m *Manager) SetupTwitch() {
|
||||
bot := m.twitchManager.Client().Bot
|
||||
if bot == nil {
|
||||
m.logger.Warn("Twitch bot is offline or not configured, could not setup commands")
|
||||
return
|
||||
}
|
||||
|
||||
// Add loyalty-based commands
|
||||
bot.RegisterCommand(commandRedeem, twitch.BotCommand{
|
||||
Description: "Redeem a reward with loyalty points",
|
||||
Usage: fmt.Sprintf("%s <reward-id> [request text]", commandRedeem),
|
||||
AccessLevel: twitch.ALTEveryone,
|
||||
Handler: m.cmdRedeemReward,
|
||||
Enabled: true,
|
||||
})
|
||||
bot.RegisterCommand(commandBalance, twitch.BotCommand{
|
||||
Description: "See your current point balance",
|
||||
Usage: commandBalance,
|
||||
AccessLevel: twitch.ALTEveryone,
|
||||
Handler: m.cmdBalance,
|
||||
Enabled: true,
|
||||
})
|
||||
bot.RegisterCommand(commandGoals, twitch.BotCommand{
|
||||
Description: "Check currently active community goals",
|
||||
Usage: commandGoals,
|
||||
AccessLevel: twitch.ALTEveryone,
|
||||
Handler: m.cmdGoalList,
|
||||
Enabled: true,
|
||||
})
|
||||
bot.RegisterCommand(commandContribute, twitch.BotCommand{
|
||||
Description: "Contribute points to a community goal",
|
||||
Usage: fmt.Sprintf("%s <points> [<goal-id>]", commandContribute),
|
||||
AccessLevel: twitch.ALTEveryone,
|
||||
Handler: m.cmdContributeGoal,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
// Setup message handler for tracking user activity
|
||||
bot.OnMessage.Add(m)
|
||||
|
||||
// Setup handler for adding points over time
|
||||
go func() {
|
||||
config := m.Config.Get()
|
||||
// Stop handler if loyalty system is disabled or there is no valid point interval
|
||||
if !config.Enabled || config.Points.Interval <= 0 {
|
||||
return
|
||||
}
|
||||
for {
|
||||
// Wait for next poll
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case <-m.restartTwitchHandler:
|
||||
return
|
||||
case <-time.After(time.Duration(config.Points.Interval) * time.Second):
|
||||
}
|
||||
|
||||
client := m.twitchManager.Client()
|
||||
|
||||
// If stream is confirmed offline, don't give points away!
|
||||
isOnline := client.IsLive()
|
||||
if !isOnline {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get user list
|
||||
cursor := ""
|
||||
var users []string
|
||||
for {
|
||||
userClient, err := client.GetUserClient(false)
|
||||
if err != nil {
|
||||
m.logger.Error("Could not get user api client for list of chatters", zap.Error(err))
|
||||
return
|
||||
}
|
||||
res, err := userClient.GetChannelChatChatters(&helix.GetChatChattersParams{
|
||||
BroadcasterID: client.User.ID,
|
||||
ModeratorID: client.User.ID,
|
||||
First: "1000",
|
||||
After: cursor,
|
||||
})
|
||||
if err != nil {
|
||||
m.logger.Error("Could not retrieve list of chatters", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, user := range res.Data.Chatters {
|
||||
users = append(users, user.UserLogin)
|
||||
}
|
||||
cursor = res.Data.Pagination.Cursor
|
||||
if cursor == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate for each user in the list
|
||||
pointsToGive := make(map[string]int64)
|
||||
for _, user := range users {
|
||||
// Check if user is blocked
|
||||
if m.IsBanned(user) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if user was active (chatting) for the bonus dingus
|
||||
award := config.Points.Amount
|
||||
if m.IsActive(user) {
|
||||
award += config.Points.ActivityBonus
|
||||
}
|
||||
|
||||
// Add to point pool if already on it, otherwise initialize
|
||||
pointsToGive[user] = award
|
||||
}
|
||||
|
||||
m.ResetActivity()
|
||||
|
||||
// If changes were made, save the pool!
|
||||
if len(users) > 0 {
|
||||
err := m.GivePoints(pointsToGive)
|
||||
if err != nil {
|
||||
m.logger.Error("Error awarding loyalty points to user", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
m.logger.Info("Loyalty system integration with Twitch is ready")
|
||||
}
|
||||
|
||||
func (m *Manager) StopTwitch() {
|
||||
bot := m.twitchManager.Client().Bot
|
||||
if bot != nil {
|
||||
bot.RemoveCommand(commandRedeem)
|
||||
bot.RemoveCommand(commandBalance)
|
||||
bot.RemoveCommand(commandGoals)
|
||||
bot.RemoveCommand(commandContribute)
|
||||
|
||||
// Remove message handler
|
||||
bot.OnMessage.Remove(m)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) HandleBotMessage(message irc.PrivateMessage) {
|
||||
m.activeUsers.SetKey(message.User.Name, true)
|
||||
}
|
||||
|
||||
func (m *Manager) SetBanList(banned []string) {
|
||||
m.banlist = make(map[string]bool)
|
||||
for _, usr := range banned {
|
||||
m.banlist[usr] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) IsBanned(user string) bool {
|
||||
banned, ok := m.banlist[user]
|
||||
return ok && banned
|
||||
}
|
||||
|
||||
func (m *Manager) IsActive(user string) bool {
|
||||
active, ok := m.activeUsers.GetKey(user)
|
||||
return ok && active
|
||||
}
|
||||
|
||||
func (m *Manager) ResetActivity() {
|
||||
m.activeUsers = sync.NewMap[string, bool]()
|
||||
}
|
||||
|
||||
func (m *Manager) cmdBalance(bot *twitch.Bot, message irc.PrivateMessage) {
|
||||
// Get user balance
|
||||
balance := m.GetPoints(message.User.Name)
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s: You have %d %s!", message.User.DisplayName, balance, m.Config.Get().Currency))
|
||||
}
|
||||
|
||||
func (m *Manager) cmdRedeemReward(bot *twitch.Bot, message irc.PrivateMessage) {
|
||||
parts := strings.Fields(message.Message)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
redeemID := parts[1]
|
||||
|
||||
// Find reward
|
||||
reward := m.GetReward(redeemID)
|
||||
if reward.ID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Reward not active, return early
|
||||
if !reward.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Get user balance
|
||||
balance := m.GetPoints(message.User.Name)
|
||||
config := m.Config.Get()
|
||||
|
||||
// Check if user can afford the reward
|
||||
if balance-reward.Price < 0 {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("I'm sorry %s but you cannot afford this (have %d %s, need %d)", message.User.DisplayName, balance, config.Currency, reward.Price))
|
||||
return
|
||||
}
|
||||
|
||||
text := ""
|
||||
if len(parts) > 2 {
|
||||
text = strings.Join(parts[2:], " ")
|
||||
}
|
||||
|
||||
// Perform redeem
|
||||
if err := m.PerformRedeem(Redeem{
|
||||
Username: message.User.Name,
|
||||
DisplayName: message.User.DisplayName,
|
||||
When: time.Now(),
|
||||
Reward: reward,
|
||||
RequestText: text,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrRedeemInCooldown) {
|
||||
nextAvailable := m.GetRewardCooldown(reward.ID)
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s: That reward is in cooldown (available in %s)", message.User.DisplayName,
|
||||
time.Until(nextAvailable).Truncate(time.Second)))
|
||||
return
|
||||
}
|
||||
m.logger.Error("Error while performing redeem", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("HolidayPresent %s has redeemed %s! (new balance: %d %s)", message.User.DisplayName, reward.Name, m.GetPoints(message.User.Name), config.Currency))
|
||||
}
|
||||
|
||||
func (m *Manager) cmdGoalList(bot *twitch.Bot, message irc.PrivateMessage) {
|
||||
goals := m.Goals.Get()
|
||||
if len(goals) < 1 {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s: There are no active community goals right now :(!", message.User.DisplayName))
|
||||
return
|
||||
}
|
||||
msg := "Current goals: "
|
||||
for _, goal := range goals {
|
||||
if !goal.Enabled {
|
||||
continue
|
||||
}
|
||||
msg += fmt.Sprintf("%s (%d/%d %s) [id: %s] | ", goal.Name, goal.Contributed, goal.TotalGoal, m.Config.Get().Currency, goal.ID)
|
||||
}
|
||||
msg += " Contribute with <!contribute POINTS GOALID>"
|
||||
bot.Client.Say(message.Channel, msg)
|
||||
}
|
||||
|
||||
func (m *Manager) cmdContributeGoal(bot *twitch.Bot, message irc.PrivateMessage) {
|
||||
goals := m.Goals.Get()
|
||||
|
||||
// Set defaults if user doesn't provide them
|
||||
points := int64(100)
|
||||
goalIndex := -1
|
||||
hasGoals := false
|
||||
|
||||
// Get first unreached goal for default
|
||||
for index, goal := range goals {
|
||||
if !goal.Enabled {
|
||||
continue
|
||||
}
|
||||
hasGoals = true
|
||||
if goal.Contributed < goal.TotalGoal {
|
||||
goalIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Do we not have any goal we can contribute to? Hooray I guess?
|
||||
if goalIndex < 0 {
|
||||
if hasGoals {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s: All active community goals have been reached already! NewRecord", message.User.DisplayName))
|
||||
} else {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s: There are no active community goals right now :(!", message.User.DisplayName))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse parameters if provided
|
||||
parts := strings.Fields(message.Message)
|
||||
if len(parts) > 1 {
|
||||
newPoints, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err == nil {
|
||||
if newPoints <= 0 {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("Nice try %s SoBayed", message.User.DisplayName))
|
||||
return
|
||||
}
|
||||
points = newPoints
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
found := false
|
||||
goalID := parts[2]
|
||||
// Find Goal index
|
||||
for index, goal := range goals {
|
||||
if !goal.Enabled {
|
||||
continue
|
||||
}
|
||||
if goal.ID == goalID {
|
||||
goalIndex = index
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Invalid goal ID provided
|
||||
if !found {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s: I couldn't find that goal ID :(", message.User.DisplayName))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get goal
|
||||
selectedGoal := goals[goalIndex]
|
||||
|
||||
// Check if goal was reached already
|
||||
if selectedGoal.Contributed >= selectedGoal.TotalGoal {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s: This goal was already reached! ヾ(•ω•`)o", message.User.DisplayName))
|
||||
return
|
||||
}
|
||||
|
||||
// Add points to goal
|
||||
points, err := m.PerformContribution(selectedGoal, message.User.Name, points)
|
||||
if err != nil {
|
||||
m.logger.Error("Error while contributing to goal", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if points == 0 {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s: Sorry but you're broke", message.User.DisplayName))
|
||||
return
|
||||
}
|
||||
|
||||
selectedGoal = m.Goals.Get()[goalIndex]
|
||||
config := m.Config.Get()
|
||||
newRemaining := selectedGoal.TotalGoal - selectedGoal.Contributed
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("NewRecord %s contributed %d %s to \"%s\"!! Only %d %s left!", message.User.DisplayName, points, config.Currency, selectedGoal.Name, newRemaining, config.Currency))
|
||||
|
||||
// Check if goal was reached!
|
||||
// TODO Replace this with sub from loyalty system or something?
|
||||
if newRemaining <= 0 {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("FallWinning The community goal \"%s\" was reached! FallWinning", selectedGoal.Name))
|
||||
}
|
||||
}
|
41
problem.go
41
problem.go
|
@ -1,41 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProblemID string
|
||||
|
||||
const (
|
||||
ProblemIDTwitchNoApp = "twitch:app_missing"
|
||||
ProblemIDEventSubNoAuth = "twitch:eventsub_no_auth"
|
||||
ProblemIDEventSubScope = "twitch:eventsub_scope"
|
||||
)
|
||||
|
||||
type Problem struct {
|
||||
ID ProblemID `json:"id"`
|
||||
Details any `json:"details"`
|
||||
}
|
||||
|
||||
func (a *App) GetProblems() (problems []Problem) {
|
||||
problems = []Problem{}
|
||||
if a.twitchManager != nil {
|
||||
client := a.twitchManager.Client()
|
||||
if client != nil {
|
||||
// Check if the app needs to be authorized again
|
||||
scopesMatch, err := twitch.CheckScopes(client.DB)
|
||||
if err != nil {
|
||||
logger.Warn("Could not check scopes for problems", zap.Error(err))
|
||||
} else {
|
||||
if !scopesMatch {
|
||||
problems = append(problems, Problem{
|
||||
ID: ProblemIDEventSubScope,
|
||||
Details: nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
package alerts
|
||||
|
||||
import (
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
)
|
||||
|
||||
const ConfigKey = "twitch/alerts/config"
|
||||
|
||||
type eventSubNotification struct {
|
||||
Subscription helix.EventSubSubscription `json:"subscription"`
|
||||
Challenge string `json:"challenge"`
|
||||
Event jsoniter.RawMessage `json:"event" desc:"Event payload, as JSON object"`
|
||||
}
|
||||
|
||||
type subscriptionVariation struct {
|
||||
MinStreak *int `json:"min_streak,omitempty" desc:"Minimum streak to get this message"`
|
||||
IsGifted *bool `json:"is_gifted,omitempty" desc:"If true, only gifted subscriptions will get these messages"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
|
||||
}
|
||||
|
||||
type giftSubVariation struct {
|
||||
MinCumulative *int `json:"min_cumulative,omitempty" desc:"Minimum cumulative amount to get this message"`
|
||||
IsAnonymous *bool `json:"is_anonymous,omitempty" desc:"If true, only anonymous gifts will get these messages"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
|
||||
}
|
||||
|
||||
type raidVariation struct {
|
||||
MinViewers *int `json:"min_viewers,omitempty" desc:"Minimum number of viewers to get this message"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
|
||||
}
|
||||
|
||||
type cheerVariation struct {
|
||||
MinAmount *int `json:"min_amount,omitempty" desc:"Minimum amount to get this message"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Follow struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on follow"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on follow, one at random will be picked"`
|
||||
Announce bool `json:"announce" desc:"If true, send as announcement"`
|
||||
} `json:"follow"`
|
||||
Subscription struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on subscription"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
|
||||
Announce bool `json:"announce" desc:"If true, send as announcement"`
|
||||
Variations []subscriptionVariation `json:"variations"`
|
||||
} `json:"subscription"`
|
||||
GiftSub struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on gifted subscription"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
|
||||
Announce bool `json:"announce" desc:"If true, send as announcement"`
|
||||
Variations []giftSubVariation `json:"variations"`
|
||||
} `json:"gift_sub"`
|
||||
Raid struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on raid"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
|
||||
Announce bool `json:"announce" desc:"If true, send as announcement"`
|
||||
Variations []raidVariation `json:"variations"`
|
||||
} `json:"raid"`
|
||||
Cheer struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on cheer"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
|
||||
Announce bool `json:"announce" desc:"If true, send as announcement"`
|
||||
Variations []cheerVariation `json:"variations"`
|
||||
} `json:"cheer"`
|
||||
}
|
|
@ -1,210 +0,0 @@
|
|||
package alerts
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"text/template"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
|
||||
)
|
||||
|
||||
func (m *Module) onEventSubEvent(_ string, value string) {
|
||||
var ev eventSubNotification
|
||||
if err := json.UnmarshalFromString(value, &ev); err != nil {
|
||||
m.logger.Warn("Error parsing webhook payload", zap.Error(err))
|
||||
return
|
||||
}
|
||||
switch ev.Subscription.Type {
|
||||
case helix.EventSubTypeChannelFollow:
|
||||
// Only process if we care about follows
|
||||
if !m.Config.Follow.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as a follow event
|
||||
var followEv helix.EventSubChannelFollowEvent
|
||||
if err := json.Unmarshal(ev.Event, &followEv); err != nil {
|
||||
m.logger.Warn("Error parsing follow event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Pick a random message
|
||||
messageID := rand.Intn(len(m.Config.Follow.Messages))
|
||||
// Pick compiled template or fallback to plain text
|
||||
tpl, ok := m.templates[templateTypeFollow][m.Config.Follow.Messages[messageID]]
|
||||
if !ok {
|
||||
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
|
||||
Message: m.Config.Follow.Messages[messageID],
|
||||
Announce: m.Config.Follow.Announce,
|
||||
})
|
||||
return
|
||||
}
|
||||
m.writeTemplate(tpl, &followEv, m.Config.Follow.Announce)
|
||||
// Compile template and send
|
||||
case helix.EventSubTypeChannelRaid:
|
||||
// Only process if we care about raids
|
||||
if !m.Config.Raid.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as raid event
|
||||
var raidEv helix.EventSubChannelRaidEvent
|
||||
|
||||
if err := json.Unmarshal(ev.Event, &raidEv); err != nil {
|
||||
m.logger.Warn("Error parsing raid event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Pick a random message from base set
|
||||
messageID := rand.Intn(len(m.Config.Raid.Messages))
|
||||
tpl, ok := m.templates[templateTypeRaid][m.Config.Raid.Messages[messageID]]
|
||||
if !ok {
|
||||
// Broken template!
|
||||
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
|
||||
Message: m.Config.Raid.Messages[messageID],
|
||||
Announce: m.Config.Raid.Announce,
|
||||
})
|
||||
return
|
||||
}
|
||||
// If we have variations, get the available variations and pick the one with the highest minimum viewers that are met
|
||||
if len(m.Config.Raid.Variations) > 0 {
|
||||
variation := getBestValidVariation(m.Config.Raid.Variations, func(variation raidVariation) int {
|
||||
if variation.MinViewers != nil && raidEv.Viewers >= *variation.MinViewers {
|
||||
return *variation.MinViewers
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeRaid, variation.Messages)
|
||||
}
|
||||
// Compile template and send
|
||||
m.writeTemplate(tpl, &raidEv, m.Config.Raid.Announce)
|
||||
case helix.EventSubTypeChannelCheer:
|
||||
// Only process if we care about bits
|
||||
if !m.Config.Cheer.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as cheer event
|
||||
var cheerEv helix.EventSubChannelCheerEvent
|
||||
if err := json.Unmarshal(ev.Event, &cheerEv); err != nil {
|
||||
m.logger.Warn("Error parsing cheer event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Pick a random message from base set
|
||||
messageID := rand.Intn(len(m.Config.Cheer.Messages))
|
||||
tpl, ok := m.templates[templateTypeCheer][m.Config.Cheer.Messages[messageID]]
|
||||
if !ok {
|
||||
// Broken template!
|
||||
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
|
||||
Message: m.Config.Cheer.Messages[messageID],
|
||||
Announce: m.Config.Cheer.Announce,
|
||||
})
|
||||
return
|
||||
}
|
||||
// If we have variations, get the available variations and pick the one with the highest minimum amount that is met
|
||||
if len(m.Config.Cheer.Variations) > 0 {
|
||||
variation := getBestValidVariation(m.Config.Cheer.Variations, func(variation cheerVariation) int {
|
||||
if variation.MinAmount != nil && cheerEv.Bits >= *variation.MinAmount {
|
||||
return *variation.MinAmount
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeCheer, variation.Messages)
|
||||
}
|
||||
// Compile template and send
|
||||
m.writeTemplate(tpl, &cheerEv, m.Config.Cheer.Announce)
|
||||
case helix.EventSubTypeChannelSubscription:
|
||||
// Only process if we care about subscriptions
|
||||
if !m.Config.Subscription.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as subscription event
|
||||
var subEv helix.EventSubChannelSubscribeEvent
|
||||
if err := json.Unmarshal(ev.Event, &subEv); err != nil {
|
||||
m.logger.Warn("Error parsing new subscription event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
m.addMixedEvent(subEv)
|
||||
case helix.EventSubTypeChannelSubscriptionMessage:
|
||||
// Only process if we care about subscriptions
|
||||
if !m.Config.Subscription.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as subscription event
|
||||
var subEv helix.EventSubChannelSubscriptionMessageEvent
|
||||
err := json.Unmarshal(ev.Event, &subEv)
|
||||
if err != nil {
|
||||
m.logger.Warn("Error parsing returning subscription event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
m.addMixedEvent(subEv)
|
||||
case helix.EventSubTypeChannelSubscriptionGift:
|
||||
// Only process if we care about gifted subs
|
||||
if !m.Config.GiftSub.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as gift event
|
||||
var giftEv helix.EventSubChannelSubscriptionGiftEvent
|
||||
if err := json.Unmarshal(ev.Event, &giftEv); err != nil {
|
||||
m.logger.Warn("Error parsing subscription gifted event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Pick a random message from base set
|
||||
messageID := rand.Intn(len(m.Config.GiftSub.Messages))
|
||||
tpl, ok := m.templates[templateTypeGift][m.Config.GiftSub.Messages[messageID]]
|
||||
if !ok {
|
||||
// Broken template!
|
||||
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
|
||||
Message: m.Config.GiftSub.Messages[messageID],
|
||||
Announce: m.Config.GiftSub.Announce,
|
||||
})
|
||||
return
|
||||
}
|
||||
// If we have variations, loop through all the available variations and pick the one with the highest minimum cumulative total that are met
|
||||
if len(m.Config.GiftSub.Variations) > 0 {
|
||||
if giftEv.IsAnonymous {
|
||||
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
|
||||
if variation.IsAnonymous != nil && *variation.IsAnonymous {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
|
||||
} else if giftEv.CumulativeTotal > 0 {
|
||||
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
|
||||
if variation.MinCumulative != nil && *variation.MinCumulative > giftEv.CumulativeTotal {
|
||||
return *variation.MinCumulative
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
|
||||
}
|
||||
}
|
||||
// Compile template and send
|
||||
m.writeTemplate(tpl, &giftEv, m.Config.GiftSub.Announce)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) replaceWithVariation(tpl *template.Template, templateType templateType, messages []string) *template.Template {
|
||||
if messages != nil {
|
||||
messageID := rand.Intn(len(messages))
|
||||
// Make sure the template is valid
|
||||
if temp, ok := m.templates[templateType][messages[messageID]]; ok {
|
||||
return temp
|
||||
}
|
||||
}
|
||||
return tpl
|
||||
}
|
||||
|
||||
// For variations, some variations are better than others, this function returns the best one
|
||||
// by using a provided score function. The score is 0 or less if the variation is not valid,
|
||||
// and 1 or more if it is valid. The variation with the highest score is returned.
|
||||
func getBestValidVariation[T any](variations []T, filterFunc func(T) int) T {
|
||||
var best T
|
||||
var bestScore int
|
||||
for _, variation := range variations {
|
||||
score := filterFunc(variation)
|
||||
if score > bestScore {
|
||||
best = variation
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
|
@ -1,135 +0,0 @@
|
|||
package alerts
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
|
||||
)
|
||||
|
||||
type subMixedEvent struct {
|
||||
UserID string
|
||||
UserLogin string
|
||||
UserName string
|
||||
BroadcasterUserID string
|
||||
BroadcasterUserLogin string
|
||||
BroadcasterUserName string
|
||||
Tier string
|
||||
IsGift bool
|
||||
CumulativeMonths int
|
||||
StreakMonths int
|
||||
DurationMonths int
|
||||
Message helix.EventSubMessage
|
||||
}
|
||||
|
||||
// Subscriptions are handled with a slight delay as info come from different events and must be aggregated
|
||||
func (m *Module) addMixedEvent(event any) {
|
||||
switch sub := event.(type) {
|
||||
case helix.EventSubChannelSubscribeEvent:
|
||||
m.pendingMux.Lock()
|
||||
defer m.pendingMux.Unlock()
|
||||
if ev, ok := m.pendingSubs[sub.UserID]; ok {
|
||||
// Already pending, add extra data
|
||||
ev.IsGift = sub.IsGift
|
||||
m.pendingSubs[sub.UserID] = ev
|
||||
return
|
||||
}
|
||||
m.pendingSubs[sub.UserID] = subMixedEvent{
|
||||
UserID: sub.UserID,
|
||||
UserLogin: sub.UserLogin,
|
||||
UserName: sub.UserName,
|
||||
BroadcasterUserID: sub.BroadcasterUserID,
|
||||
BroadcasterUserLogin: sub.BroadcasterUserLogin,
|
||||
BroadcasterUserName: sub.BroadcasterUserName,
|
||||
Tier: sub.Tier,
|
||||
IsGift: sub.IsGift,
|
||||
}
|
||||
go func() {
|
||||
// Wait a bit to make sure we aggregate all events
|
||||
time.Sleep(time.Second * 3)
|
||||
m.processPendingSub(sub.UserID)
|
||||
}()
|
||||
case helix.EventSubChannelSubscriptionMessageEvent:
|
||||
m.pendingMux.Lock()
|
||||
defer m.pendingMux.Unlock()
|
||||
if ev, ok := m.pendingSubs[sub.UserID]; ok {
|
||||
// Already pending, add extra data
|
||||
ev.StreakMonths = sub.StreakMonths
|
||||
ev.DurationMonths = sub.DurationMonths
|
||||
ev.CumulativeMonths = sub.CumulativeMonths
|
||||
ev.Message = sub.Message
|
||||
return
|
||||
}
|
||||
m.pendingSubs[sub.UserID] = subMixedEvent{
|
||||
UserID: sub.UserID,
|
||||
UserLogin: sub.UserLogin,
|
||||
UserName: sub.UserName,
|
||||
BroadcasterUserID: sub.BroadcasterUserID,
|
||||
BroadcasterUserLogin: sub.BroadcasterUserLogin,
|
||||
BroadcasterUserName: sub.BroadcasterUserName,
|
||||
Tier: sub.Tier,
|
||||
StreakMonths: sub.StreakMonths,
|
||||
DurationMonths: sub.DurationMonths,
|
||||
CumulativeMonths: sub.CumulativeMonths,
|
||||
Message: sub.Message,
|
||||
}
|
||||
go func() {
|
||||
// Wait a bit to make sure we aggregate all events
|
||||
time.Sleep(time.Second * 3)
|
||||
m.processPendingSub(sub.UserID)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) processPendingSub(user string) {
|
||||
m.pendingMux.Lock()
|
||||
defer m.pendingMux.Unlock()
|
||||
sub, ok := m.pendingSubs[user]
|
||||
defer delete(m.pendingSubs, user)
|
||||
if !ok {
|
||||
// Somehow it's gone? Return early
|
||||
return
|
||||
}
|
||||
|
||||
// One last check in case config changed
|
||||
if !m.Config.Subscription.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Assign random message
|
||||
messageID := rand.Intn(len(m.Config.Subscription.Messages))
|
||||
tpl, ok := m.templates[templateTypeSubscription][m.Config.Subscription.Messages[messageID]]
|
||||
|
||||
// If template is broken, write it as is (soft fail, plus we raise attention I guess?)
|
||||
if !ok {
|
||||
// Broken template!
|
||||
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
|
||||
Message: m.Config.Subscription.Messages[messageID],
|
||||
Announce: m.Config.Subscription.Announce,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for variations, either by streak or gifted
|
||||
if sub.IsGift {
|
||||
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
|
||||
if variation.IsGifted != nil && *variation.IsGifted {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
|
||||
} else if sub.DurationMonths > 0 {
|
||||
// Get variation with the highest minimum streak that's met
|
||||
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
|
||||
if variation.MinStreak != nil && sub.DurationMonths >= *variation.MinStreak {
|
||||
return sub.DurationMonths
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
|
||||
}
|
||||
m.writeTemplate(tpl, sub, m.Config.Subscription.Announce)
|
||||
}
|
|
@ -1,102 +0,0 @@
|
|||
package alerts
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
var json = jsoniter.ConfigFastest
|
||||
|
||||
type (
|
||||
templateCache map[string]*template.Template
|
||||
templateCacheMap map[templateType]templateCache
|
||||
)
|
||||
|
||||
type templateType string
|
||||
|
||||
const (
|
||||
templateTypeSubscription templateType = "subscription"
|
||||
templateTypeFollow templateType = "follow"
|
||||
templateTypeRaid templateType = "raid"
|
||||
templateTypeCheer templateType = "cheer"
|
||||
templateTypeGift templateType = "gift"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
Config Config
|
||||
|
||||
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 {
|
||||
mod := &Module{
|
||||
db: db,
|
||||
logger: logger,
|
||||
templater: templater,
|
||||
pendingMux: sync.Mutex{},
|
||||
pendingSubs: make(map[string]subMixedEvent),
|
||||
}
|
||||
|
||||
// Load config from database
|
||||
err := db.GetJSON(ConfigKey, &mod.Config)
|
||||
if err != nil {
|
||||
logger.Debug("Config load error", zap.Error(err))
|
||||
mod.Config = Config{}
|
||||
// Save empty config
|
||||
err = db.PutJSON(ConfigKey, mod.Config)
|
||||
if err != nil {
|
||||
logger.Warn("Could not save default config for bot alerts", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
mod.compileTemplates()
|
||||
|
||||
mod.cancelAlertSub, err = db.SubscribeKey(ConfigKey, func(value string) {
|
||||
err := json.UnmarshalFromString(value, &mod.Config)
|
||||
if err != nil {
|
||||
logger.Warn("Error loading alert config", zap.Error(err))
|
||||
} else {
|
||||
logger.Info("Reloaded alert config")
|
||||
}
|
||||
mod.compileTemplates()
|
||||
})
|
||||
if 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 {
|
||||
logger.Error("Could not setup twitch alert subscription", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Debug("Loaded bot alerts")
|
||||
|
||||
return mod
|
||||
}
|
||||
|
||||
func (m *Module) Close() {
|
||||
if m.cancelAlertSub != nil {
|
||||
m.cancelAlertSub()
|
||||
}
|
||||
if m.cancelTwitchEventSub != nil {
|
||||
m.cancelTwitchEventSub()
|
||||
}
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
package alerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
|
||||
)
|
||||
|
||||
func (m *Module) compileTemplates() {
|
||||
// Reset caches
|
||||
m.templates = templateCacheMap{
|
||||
templateTypeSubscription: make(templateCache),
|
||||
templateTypeFollow: make(templateCache),
|
||||
templateTypeRaid: make(templateCache),
|
||||
templateTypeCheer: make(templateCache),
|
||||
templateTypeGift: make(templateCache),
|
||||
}
|
||||
|
||||
// Add base templates
|
||||
m.addTemplatesForType(templateTypeFollow, m.Config.Follow.Messages)
|
||||
m.addTemplatesForType(templateTypeSubscription, m.Config.Subscription.Messages)
|
||||
m.addTemplatesForType(templateTypeRaid, m.Config.Raid.Messages)
|
||||
m.addTemplatesForType(templateTypeCheer, m.Config.Cheer.Messages)
|
||||
m.addTemplatesForType(templateTypeGift, m.Config.GiftSub.Messages)
|
||||
|
||||
// Add variations
|
||||
for _, variation := range m.Config.Subscription.Variations {
|
||||
m.addTemplatesForType(templateTypeSubscription, variation.Messages)
|
||||
}
|
||||
for _, variation := range m.Config.Raid.Variations {
|
||||
m.addTemplatesForType(templateTypeRaid, variation.Messages)
|
||||
}
|
||||
for _, variation := range m.Config.Cheer.Variations {
|
||||
m.addTemplatesForType(templateTypeCheer, variation.Messages)
|
||||
}
|
||||
for _, variation := range m.Config.GiftSub.Variations {
|
||||
m.addTemplatesForType(templateTypeGift, variation.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) addTemplate(templateList templateCache, message string) {
|
||||
tpl, err := m.templater.MakeTemplate(message)
|
||||
if err != nil {
|
||||
m.logger.Error("Error compiling alert template", zap.Error(err))
|
||||
return
|
||||
}
|
||||
templateList[message] = tpl
|
||||
}
|
||||
|
||||
func (m *Module) addTemplatesForType(templateList templateType, messages []string) {
|
||||
for _, message := range messages {
|
||||
m.addTemplate(m.templates[templateList], message)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTemplate renders the template and sends the message to the channel
|
||||
func (m *Module) writeTemplate(tpl *template.Template, data interface{}, announce bool) {
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
m.logger.Error("Error executing template for bot alert", zap.Error(err))
|
||||
return
|
||||
}
|
||||
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
|
||||
Message: buf.String(),
|
||||
Announce: announce,
|
||||
})
|
||||
}
|
114
twitch/api.go
114
twitch/api.go
|
@ -1,114 +0,0 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
)
|
||||
|
||||
func GetConfig(db database.Database) (Config, error) {
|
||||
var config Config
|
||||
if err := db.GetJSON(ConfigKey, &config); err != nil {
|
||||
return Config{}, fmt.Errorf("failed to get twitch config: %w", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope []string `json:"scope"`
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
func GetUserClient(db database.Database, forceRefresh bool) (*helix.Client, error) {
|
||||
var authResp AuthResponse
|
||||
if err := db.GetJSON(AuthKey, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Handle token expiration
|
||||
if forceRefresh || time.Now().After(authResp.Time.Add(time.Duration(authResp.ExpiresIn)*time.Second)) {
|
||||
// Refresh tokens
|
||||
api, err := GetHelixAPI(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshed, err := api.RefreshUserAccessToken(authResp.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authResp.AccessToken = refreshed.Data.AccessToken
|
||||
authResp.RefreshToken = refreshed.Data.RefreshToken
|
||||
authResp.Time = time.Now().Add(time.Duration(refreshed.Data.ExpiresIn) * time.Second)
|
||||
|
||||
// Save new token pair
|
||||
err = db.PutJSON(AuthKey, authResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
config, err := GetConfig(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return helix.NewClient(&helix.Options{
|
||||
ClientID: config.APIClientID,
|
||||
ClientSecret: config.APIClientSecret,
|
||||
UserAccessToken: authResp.AccessToken,
|
||||
})
|
||||
}
|
||||
|
||||
func GetHelixAPI(db database.Database) (*helix.Client, error) {
|
||||
config, err := GetConfig(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseurl, err := baseURL(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
redirectURI := getRedirectURI(baseurl)
|
||||
|
||||
// Create Twitch client
|
||||
api, err := helix.NewClient(&helix.Options{
|
||||
ClientID: config.APIClientID,
|
||||
ClientSecret: config.APIClientSecret,
|
||||
RedirectURI: redirectURI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get access token
|
||||
resp, err := api.RequestAppAccessToken([]string{"user:read:email"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Set the access token on the client
|
||||
api.SetAppAccessToken(resp.Data.AccessToken)
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func baseURL(db database.Database) (string, error) {
|
||||
var severConfig struct {
|
||||
Bind string `json:"bind"`
|
||||
}
|
||||
err := db.GetJSON("http/config", &severConfig)
|
||||
return severConfig.Bind, err
|
||||
}
|
||||
|
||||
func getRedirectURI(baseurl string) string {
|
||||
return fmt.Sprintf("http://%s/twitch/callback", baseurl)
|
||||
}
|
523
twitch/bot.alerts.go
Normal file
523
twitch/bot.alerts.go
Normal file
|
@ -0,0 +1,523 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
)
|
||||
|
||||
const BotAlertsKey = "twitch/bot-modules/alerts/config"
|
||||
|
||||
type eventSubNotification struct {
|
||||
Subscription helix.EventSubSubscription `json:"subscription"`
|
||||
Challenge string `json:"challenge"`
|
||||
Event jsoniter.RawMessage `json:"event" desc:"Event payload, as JSON object"`
|
||||
}
|
||||
|
||||
type subscriptionVariation struct {
|
||||
MinStreak *int `json:"min_streak,omitempty" desc:"Minimum streak to get this message"`
|
||||
IsGifted *bool `json:"is_gifted,omitempty" desc:"If true, only gifted subscriptions will get these messages"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
|
||||
}
|
||||
|
||||
type giftSubVariation struct {
|
||||
MinCumulative *int `json:"min_cumulative,omitempty" desc:"Minimum cumulative amount to get this message"`
|
||||
IsAnonymous *bool `json:"is_anonymous,omitempty" desc:"If true, only anonymous gifts will get these messages"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
|
||||
}
|
||||
|
||||
type raidVariation struct {
|
||||
MinViewers *int `json:"min_viewers,omitempty" desc:"Minimum number of viewers to get this message"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
|
||||
}
|
||||
|
||||
type cheerVariation struct {
|
||||
MinAmount *int `json:"min_amount,omitempty" desc:"Minimum amount to get this message"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
|
||||
}
|
||||
|
||||
type BotAlertsConfig struct {
|
||||
Follow struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on follow"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on follow, one at random will be picked"`
|
||||
} `json:"follow"`
|
||||
Subscription struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on subscription"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
|
||||
Variations []subscriptionVariation `json:"variations"`
|
||||
} `json:"subscription"`
|
||||
GiftSub struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on gifted subscription"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
|
||||
Variations []giftSubVariation `json:"variations"`
|
||||
} `json:"gift_sub"`
|
||||
Raid struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on raid"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
|
||||
Variations []raidVariation `json:"variations"`
|
||||
} `json:"raid"`
|
||||
Cheer struct {
|
||||
Enabled bool `json:"enabled" desc:"Enable chat message alert on cheer"`
|
||||
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
|
||||
Variations []cheerVariation `json:"variations"`
|
||||
} `json:"cheer"`
|
||||
}
|
||||
|
||||
type (
|
||||
templateCache map[string]*template.Template
|
||||
templateCacheMap map[templateType]templateCache
|
||||
)
|
||||
|
||||
type templateType string
|
||||
|
||||
const (
|
||||
templateTypeSubscription templateType = "subscription"
|
||||
templateTypeFollow templateType = "follow"
|
||||
templateTypeRaid templateType = "raid"
|
||||
templateTypeCheer templateType = "cheer"
|
||||
templateTypeGift templateType = "gift"
|
||||
)
|
||||
|
||||
type BotAlertsModule struct {
|
||||
Config BotAlertsConfig
|
||||
|
||||
bot *Bot
|
||||
templates templateCacheMap
|
||||
|
||||
cancelAlertSub database.CancelFunc
|
||||
cancelTwitchEventSub database.CancelFunc
|
||||
|
||||
pendingMux sync.Mutex
|
||||
pendingSubs map[string]subMixedEvent
|
||||
}
|
||||
|
||||
func SetupAlerts(bot *Bot) *BotAlertsModule {
|
||||
mod := &BotAlertsModule{
|
||||
bot: bot,
|
||||
pendingMux: sync.Mutex{},
|
||||
pendingSubs: make(map[string]subMixedEvent),
|
||||
}
|
||||
|
||||
// Load config from database
|
||||
err := bot.api.db.GetJSON(BotAlertsKey, &mod.Config)
|
||||
if err != nil {
|
||||
bot.logger.Debug("Config load error", zap.Error(err))
|
||||
mod.Config = BotAlertsConfig{}
|
||||
// Save empty config
|
||||
err = bot.api.db.PutJSON(BotAlertsKey, mod.Config)
|
||||
if err != nil {
|
||||
bot.logger.Warn("Could not save default config for bot alerts", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
mod.compileTemplates()
|
||||
|
||||
mod.cancelAlertSub, err = bot.api.db.SubscribeKey(BotAlertsKey, func(value string) {
|
||||
err := json.UnmarshalFromString(value, &mod.Config)
|
||||
if err != nil {
|
||||
bot.logger.Warn("Error loading alert config", zap.Error(err))
|
||||
} else {
|
||||
bot.logger.Info("Reloaded alert config")
|
||||
}
|
||||
mod.compileTemplates()
|
||||
})
|
||||
if err != nil {
|
||||
bot.logger.Error("Could not set-up bot alert reload subscription", zap.Error(err))
|
||||
}
|
||||
|
||||
mod.cancelTwitchEventSub, err = bot.api.db.SubscribeKey(EventSubEventKey, mod.onEventSubEvent)
|
||||
if err != nil {
|
||||
bot.logger.Error("Could not setup twitch alert subscription", zap.Error(err))
|
||||
}
|
||||
|
||||
bot.logger.Debug("Loaded bot alerts")
|
||||
|
||||
return mod
|
||||
}
|
||||
|
||||
func (m *BotAlertsModule) onEventSubEvent(value string) {
|
||||
var ev eventSubNotification
|
||||
err := json.UnmarshalFromString(value, &ev)
|
||||
if err != nil {
|
||||
m.bot.logger.Warn("Error parsing webhook payload", zap.Error(err))
|
||||
return
|
||||
}
|
||||
switch ev.Subscription.Type {
|
||||
case helix.EventSubTypeChannelFollow:
|
||||
// Only process if we care about follows
|
||||
if !m.Config.Follow.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as a follow event
|
||||
var followEv helix.EventSubChannelFollowEvent
|
||||
err := json.Unmarshal(ev.Event, &followEv)
|
||||
if err != nil {
|
||||
m.bot.logger.Warn("Error parsing follow event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Pick a random message
|
||||
messageID := rand.Intn(len(m.Config.Follow.Messages))
|
||||
// Pick compiled template or fallback to plain text
|
||||
if tpl, ok := m.templates[templateTypeFollow][m.Config.Follow.Messages[messageID]]; ok {
|
||||
writeTemplate(m.bot, tpl, &followEv)
|
||||
} else {
|
||||
m.bot.WriteMessage(m.Config.Follow.Messages[messageID])
|
||||
}
|
||||
// Compile template and send
|
||||
case helix.EventSubTypeChannelRaid:
|
||||
// Only process if we care about raids
|
||||
if !m.Config.Raid.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as raid event
|
||||
var raidEv helix.EventSubChannelRaidEvent
|
||||
err := json.Unmarshal(ev.Event, &raidEv)
|
||||
if err != nil {
|
||||
m.bot.logger.Warn("Error parsing raid event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Pick a random message from base set
|
||||
messageID := rand.Intn(len(m.Config.Raid.Messages))
|
||||
tpl, ok := m.templates[templateTypeRaid][m.Config.Raid.Messages[messageID]]
|
||||
if !ok {
|
||||
// Broken template!
|
||||
m.bot.WriteMessage(m.Config.Raid.Messages[messageID])
|
||||
return
|
||||
}
|
||||
// If we have variations, get the available variations and pick the one with the highest minimum viewers that are met
|
||||
if len(m.Config.Raid.Variations) > 0 {
|
||||
variation := getBestValidVariation(m.Config.Raid.Variations, func(variation raidVariation) int {
|
||||
if variation.MinViewers != nil && raidEv.Viewers >= *variation.MinViewers {
|
||||
return *variation.MinViewers
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeRaid, variation.Messages)
|
||||
}
|
||||
// Compile template and send
|
||||
writeTemplate(m.bot, tpl, &raidEv)
|
||||
case helix.EventSubTypeChannelCheer:
|
||||
// Only process if we care about bits
|
||||
if !m.Config.Cheer.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as cheer event
|
||||
var cheerEv helix.EventSubChannelCheerEvent
|
||||
err := json.Unmarshal(ev.Event, &cheerEv)
|
||||
if err != nil {
|
||||
m.bot.logger.Warn("Error parsing cheer event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Pick a random message from base set
|
||||
messageID := rand.Intn(len(m.Config.Cheer.Messages))
|
||||
tpl, ok := m.templates[templateTypeCheer][m.Config.Cheer.Messages[messageID]]
|
||||
if !ok {
|
||||
// Broken template!
|
||||
m.bot.WriteMessage(m.Config.Raid.Messages[messageID])
|
||||
return
|
||||
}
|
||||
// If we have variations, get the available variations and pick the one with the highest minimum amount that is met
|
||||
if len(m.Config.Cheer.Variations) > 0 {
|
||||
variation := getBestValidVariation(m.Config.Cheer.Variations, func(variation cheerVariation) int {
|
||||
if variation.MinAmount != nil && cheerEv.Bits >= *variation.MinAmount {
|
||||
return *variation.MinAmount
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeCheer, variation.Messages)
|
||||
}
|
||||
// Compile template and send
|
||||
writeTemplate(m.bot, tpl, &cheerEv)
|
||||
case helix.EventSubTypeChannelSubscription:
|
||||
// Only process if we care about subscriptions
|
||||
if !m.Config.Subscription.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as subscription event
|
||||
var subEv helix.EventSubChannelSubscribeEvent
|
||||
err := json.Unmarshal(ev.Event, &subEv)
|
||||
if err != nil {
|
||||
m.bot.logger.Warn("Error parsing new subscription event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
m.addMixedEvent(subEv)
|
||||
case helix.EventSubTypeChannelSubscriptionMessage:
|
||||
// Only process if we care about subscriptions
|
||||
if !m.Config.Subscription.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as subscription event
|
||||
var subEv helix.EventSubChannelSubscriptionMessageEvent
|
||||
err := json.Unmarshal(ev.Event, &subEv)
|
||||
if err != nil {
|
||||
m.bot.logger.Warn("Error parsing returning subscription event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
m.addMixedEvent(subEv)
|
||||
case helix.EventSubTypeChannelSubscriptionGift:
|
||||
// Only process if we care about gifted subs
|
||||
if !m.Config.GiftSub.Enabled {
|
||||
return
|
||||
}
|
||||
// Parse as gift event
|
||||
var giftEv helix.EventSubChannelSubscriptionGiftEvent
|
||||
err := json.Unmarshal(ev.Event, &giftEv)
|
||||
if err != nil {
|
||||
m.bot.logger.Warn("Error parsing subscription gifted event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Pick a random message from base set
|
||||
messageID := rand.Intn(len(m.Config.GiftSub.Messages))
|
||||
tpl, ok := m.templates[templateTypeGift][m.Config.GiftSub.Messages[messageID]]
|
||||
if !ok {
|
||||
// Broken template!
|
||||
m.bot.WriteMessage(m.Config.GiftSub.Messages[messageID])
|
||||
return
|
||||
}
|
||||
// If we have variations, loop through all the available variations and pick the one with the highest minimum cumulative total that are met
|
||||
if len(m.Config.GiftSub.Variations) > 0 {
|
||||
if giftEv.IsAnonymous {
|
||||
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
|
||||
if variation.IsAnonymous != nil && *variation.IsAnonymous {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
|
||||
} else if giftEv.CumulativeTotal > 0 {
|
||||
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
|
||||
if variation.MinCumulative != nil && *variation.MinCumulative > giftEv.CumulativeTotal {
|
||||
return *variation.MinCumulative
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
|
||||
}
|
||||
}
|
||||
// Compile template and send
|
||||
writeTemplate(m.bot, tpl, &giftEv)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BotAlertsModule) replaceWithVariation(tpl *template.Template, templateType templateType, messages []string) *template.Template {
|
||||
if messages != nil {
|
||||
messageID := rand.Intn(len(messages))
|
||||
// Make sure the template is valid
|
||||
if temp, ok := m.templates[templateType][messages[messageID]]; ok {
|
||||
return temp
|
||||
}
|
||||
}
|
||||
return tpl
|
||||
}
|
||||
|
||||
// Subscriptions are handled with a slight delay as info come from different events and must be aggregated
|
||||
func (m *BotAlertsModule) addMixedEvent(event any) {
|
||||
switch sub := event.(type) {
|
||||
case helix.EventSubChannelSubscribeEvent:
|
||||
m.pendingMux.Lock()
|
||||
defer m.pendingMux.Unlock()
|
||||
if ev, ok := m.pendingSubs[sub.UserID]; ok {
|
||||
// Already pending, add extra data
|
||||
ev.IsGift = sub.IsGift
|
||||
m.pendingSubs[sub.UserID] = ev
|
||||
return
|
||||
}
|
||||
m.pendingSubs[sub.UserID] = subMixedEvent{
|
||||
UserID: sub.UserID,
|
||||
UserLogin: sub.UserLogin,
|
||||
UserName: sub.UserName,
|
||||
BroadcasterUserID: sub.BroadcasterUserID,
|
||||
BroadcasterUserLogin: sub.BroadcasterUserLogin,
|
||||
BroadcasterUserName: sub.BroadcasterUserName,
|
||||
Tier: sub.Tier,
|
||||
IsGift: sub.IsGift,
|
||||
}
|
||||
go func() {
|
||||
// Wait a bit to make sure we aggregate all events
|
||||
time.Sleep(time.Second * 3)
|
||||
m.processPendingSub(sub.UserID)
|
||||
}()
|
||||
case helix.EventSubChannelSubscriptionMessageEvent:
|
||||
m.pendingMux.Lock()
|
||||
defer m.pendingMux.Unlock()
|
||||
if ev, ok := m.pendingSubs[sub.UserID]; ok {
|
||||
// Already pending, add extra data
|
||||
ev.StreakMonths = sub.StreakMonths
|
||||
ev.DurationMonths = sub.DurationMonths
|
||||
ev.CumulativeMonths = sub.CumulativeMonths
|
||||
ev.Message = sub.Message
|
||||
return
|
||||
}
|
||||
m.pendingSubs[sub.UserID] = subMixedEvent{
|
||||
UserID: sub.UserID,
|
||||
UserLogin: sub.UserLogin,
|
||||
UserName: sub.UserName,
|
||||
BroadcasterUserID: sub.BroadcasterUserID,
|
||||
BroadcasterUserLogin: sub.BroadcasterUserLogin,
|
||||
BroadcasterUserName: sub.BroadcasterUserName,
|
||||
Tier: sub.Tier,
|
||||
StreakMonths: sub.StreakMonths,
|
||||
DurationMonths: sub.DurationMonths,
|
||||
CumulativeMonths: sub.CumulativeMonths,
|
||||
Message: sub.Message,
|
||||
}
|
||||
go func() {
|
||||
// Wait a bit to make sure we aggregate all events
|
||||
time.Sleep(time.Second * 3)
|
||||
m.processPendingSub(sub.UserID)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BotAlertsModule) processPendingSub(user string) {
|
||||
m.pendingMux.Lock()
|
||||
defer m.pendingMux.Unlock()
|
||||
sub, ok := m.pendingSubs[user]
|
||||
defer delete(m.pendingSubs, user)
|
||||
if !ok {
|
||||
// Somehow it's gone? Return early
|
||||
return
|
||||
}
|
||||
|
||||
// One last check in case config changed
|
||||
if !m.Config.Subscription.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Assign random message
|
||||
messageID := rand.Intn(len(m.Config.Subscription.Messages))
|
||||
tpl, ok := m.templates[templateTypeSubscription][m.Config.Subscription.Messages[messageID]]
|
||||
|
||||
// If template is broken, write it as is (soft fail, plus we raise attention I guess?)
|
||||
if !ok {
|
||||
m.bot.WriteMessage(m.Config.Subscription.Messages[messageID])
|
||||
return
|
||||
}
|
||||
|
||||
// Check for variations, either by streak or gifted
|
||||
if sub.IsGift {
|
||||
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
|
||||
if variation.IsGifted != nil && *variation.IsGifted {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
|
||||
} else if sub.DurationMonths > 0 {
|
||||
// Get variation with the highest minimum streak that's met
|
||||
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
|
||||
if variation.MinStreak != nil && sub.DurationMonths >= *variation.MinStreak {
|
||||
return sub.DurationMonths
|
||||
}
|
||||
return 0
|
||||
})
|
||||
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
|
||||
}
|
||||
writeTemplate(m.bot, tpl, sub)
|
||||
}
|
||||
|
||||
// For variations, some variations are better than others, this function returns the best one
|
||||
// by using a provided score function. The score is 0 or less if the variation is not valid,
|
||||
// and 1 or more if it is valid. The variation with the highest score is returned.
|
||||
func getBestValidVariation[T any](variations []T, filterFunc func(T) int) T {
|
||||
var best T
|
||||
var bestScore int
|
||||
for _, variation := range variations {
|
||||
score := filterFunc(variation)
|
||||
if score > bestScore {
|
||||
best = variation
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (m *BotAlertsModule) compileTemplates() {
|
||||
// Reset caches
|
||||
m.templates = templateCacheMap{
|
||||
templateTypeSubscription: make(templateCache),
|
||||
templateTypeFollow: make(templateCache),
|
||||
templateTypeRaid: make(templateCache),
|
||||
templateTypeCheer: make(templateCache),
|
||||
templateTypeGift: make(templateCache),
|
||||
}
|
||||
|
||||
// Add base templates
|
||||
m.addTemplatesForType(templateTypeFollow, m.Config.Follow.Messages)
|
||||
m.addTemplatesForType(templateTypeSubscription, m.Config.Subscription.Messages)
|
||||
m.addTemplatesForType(templateTypeRaid, m.Config.Raid.Messages)
|
||||
m.addTemplatesForType(templateTypeCheer, m.Config.Cheer.Messages)
|
||||
m.addTemplatesForType(templateTypeGift, m.Config.GiftSub.Messages)
|
||||
|
||||
// Add variations
|
||||
for _, variation := range m.Config.Subscription.Variations {
|
||||
m.addTemplatesForType(templateTypeSubscription, variation.Messages)
|
||||
}
|
||||
for _, variation := range m.Config.Raid.Variations {
|
||||
m.addTemplatesForType(templateTypeRaid, variation.Messages)
|
||||
}
|
||||
for _, variation := range m.Config.Cheer.Variations {
|
||||
m.addTemplatesForType(templateTypeCheer, variation.Messages)
|
||||
}
|
||||
for _, variation := range m.Config.GiftSub.Variations {
|
||||
m.addTemplatesForType(templateTypeGift, variation.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BotAlertsModule) addTemplate(templateList templateCache, message string) {
|
||||
tpl, err := m.bot.MakeTemplate(message)
|
||||
if err != nil {
|
||||
m.bot.logger.Error("Error compiling alert template", zap.Error(err))
|
||||
return
|
||||
}
|
||||
templateList[message] = tpl
|
||||
}
|
||||
|
||||
func (m *BotAlertsModule) addTemplatesForType(templateList templateType, messages []string) {
|
||||
for _, message := range messages {
|
||||
m.addTemplate(m.templates[templateList], message)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BotAlertsModule) Close() {
|
||||
if m.cancelAlertSub != nil {
|
||||
m.cancelAlertSub()
|
||||
}
|
||||
if m.cancelTwitchEventSub != nil {
|
||||
m.cancelTwitchEventSub()
|
||||
}
|
||||
}
|
||||
|
||||
// writeTemplate renders the template and sends the message to the channel
|
||||
func writeTemplate(bot *Bot, tpl *template.Template, data interface{}) {
|
||||
var buf bytes.Buffer
|
||||
err := tpl.Execute(&buf, data)
|
||||
if err != nil {
|
||||
bot.logger.Error("Error executing template for bot alert", zap.Error(err))
|
||||
return
|
||||
}
|
||||
bot.WriteMessage(buf.String())
|
||||
}
|
||||
|
||||
type subMixedEvent struct {
|
||||
UserID string
|
||||
UserLogin string
|
||||
UserName string
|
||||
BroadcasterUserID string
|
||||
BroadcasterUserLogin string
|
||||
BroadcasterUserName string
|
||||
Tier string
|
||||
IsGift bool
|
||||
CumulativeMonths int
|
||||
StreakMonths int
|
||||
DurationMonths int
|
||||
Message helix.EventSubMessage
|
||||
}
|
393
twitch/bot.go
Normal file
393
twitch/bot.go
Normal file
|
@ -0,0 +1,393 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
|
||||
"git.sr.ht/~ashkeel/containers/sync"
|
||||
irc "github.com/gempir/go-twitch-irc/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||
)
|
||||
|
||||
type IRCBot interface {
|
||||
Join(channel ...string)
|
||||
|
||||
Connect() error
|
||||
Disconnect() error
|
||||
|
||||
Say(channel, message string)
|
||||
Reply(channel, messageID, message string)
|
||||
|
||||
OnConnect(handler func())
|
||||
OnPrivateMessage(handler func(irc.PrivateMessage))
|
||||
OnUserJoinMessage(handler func(message irc.UserJoinMessage))
|
||||
OnUserPartMessage(handler func(message irc.UserPartMessage))
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
Client IRCBot
|
||||
Config BotConfig
|
||||
|
||||
api *Client
|
||||
username string
|
||||
logger *zap.Logger
|
||||
lastMessage *sync.RWSync[time.Time]
|
||||
chatHistory *sync.Slice[irc.PrivateMessage]
|
||||
|
||||
commands *sync.Map[string, BotCommand]
|
||||
customCommands *sync.Map[string, BotCustomCommand]
|
||||
customTemplates *sync.Map[string, *template.Template]
|
||||
customFunctions template.FuncMap
|
||||
|
||||
OnConnect *utils.SyncList[BotConnectHandler]
|
||||
OnMessage *utils.SyncList[BotMessageHandler]
|
||||
|
||||
cancelUpdateSub database.CancelFunc
|
||||
cancelWritePlainRPCSub database.CancelFunc
|
||||
cancelWriteRPCSub database.CancelFunc
|
||||
|
||||
// Module specific vars
|
||||
Timers *BotTimerModule
|
||||
Alerts *BotAlertsModule
|
||||
}
|
||||
|
||||
type BotConnectHandler interface {
|
||||
utils.Comparable
|
||||
HandleBotConnect()
|
||||
}
|
||||
|
||||
type BotMessageHandler interface {
|
||||
utils.Comparable
|
||||
HandleBotMessage(message irc.PrivateMessage)
|
||||
}
|
||||
|
||||
func (b *Bot) Migrate(old *Bot) {
|
||||
utils.MergeSyncMap(b.commands, old.commands)
|
||||
// Get registered commands and handlers from old bot
|
||||
b.OnConnect.Copy(old.OnConnect)
|
||||
b.OnMessage.Copy(old.OnMessage)
|
||||
}
|
||||
|
||||
func newBot(api *Client, config BotConfig) *Bot {
|
||||
// Create client
|
||||
client := irc.NewClient(config.Username, config.Token)
|
||||
|
||||
return newBotWithClient(client, api, config)
|
||||
}
|
||||
|
||||
func newBotWithClient(client IRCBot, api *Client, config BotConfig) *Bot {
|
||||
bot := &Bot{
|
||||
Client: client,
|
||||
Config: config,
|
||||
|
||||
username: strings.ToLower(config.Username), // Normalize username
|
||||
logger: api.logger,
|
||||
api: api,
|
||||
lastMessage: sync.NewRWSync(time.Now()),
|
||||
commands: sync.NewMap[string, BotCommand](),
|
||||
customCommands: sync.NewMap[string, BotCustomCommand](),
|
||||
customTemplates: sync.NewMap[string, *template.Template](),
|
||||
chatHistory: sync.NewSlice[irc.PrivateMessage](),
|
||||
|
||||
OnConnect: utils.NewSyncList[BotConnectHandler](),
|
||||
OnMessage: utils.NewSyncList[BotMessageHandler](),
|
||||
}
|
||||
|
||||
client.OnConnect(bot.onConnectHandler)
|
||||
client.OnPrivateMessage(bot.onMessageHandler)
|
||||
client.OnUserJoinMessage(bot.onJoinHandler)
|
||||
client.OnUserPartMessage(bot.onPartHandler)
|
||||
|
||||
bot.Client.Join(config.Channel)
|
||||
bot.setupFunctions()
|
||||
|
||||
// Load modules
|
||||
bot.Timers = SetupTimers(bot)
|
||||
bot.Alerts = SetupAlerts(bot)
|
||||
|
||||
// Load custom commands
|
||||
var customCommands map[string]BotCustomCommand
|
||||
err := api.db.GetJSON(CustomCommandsKey, &customCommands)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrEmptyKey) {
|
||||
customCommands = make(map[string]BotCustomCommand)
|
||||
} else {
|
||||
bot.logger.Error("Failed to load custom commands", zap.Error(err))
|
||||
}
|
||||
}
|
||||
bot.customCommands.Set(customCommands)
|
||||
|
||||
err = bot.updateTemplates()
|
||||
if err != nil {
|
||||
bot.logger.Error("Failed to parse custom commands", zap.Error(err))
|
||||
}
|
||||
bot.cancelUpdateSub, err = api.db.SubscribeKey(CustomCommandsKey, bot.updateCommands)
|
||||
if err != nil {
|
||||
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
|
||||
}
|
||||
bot.cancelWritePlainRPCSub, err = api.db.SubscribeKey(WritePlainMessageRPC, bot.handleWritePlainMessageRPC)
|
||||
if err != nil {
|
||||
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
|
||||
}
|
||||
bot.cancelWriteRPCSub, err = api.db.SubscribeKey(WriteMessageRPC, bot.handleWriteMessageRPC)
|
||||
if err != nil {
|
||||
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
|
||||
}
|
||||
|
||||
return bot
|
||||
}
|
||||
|
||||
func (b *Bot) onJoinHandler(message irc.UserJoinMessage) {
|
||||
if strings.ToLower(message.User) == b.username {
|
||||
b.logger.Info("Twitch bot joined channel", zap.String("channel", message.Channel))
|
||||
} else {
|
||||
b.logger.Debug("User joined channel", zap.String("channel", message.Channel), zap.String("username", message.User))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) onPartHandler(message irc.UserPartMessage) {
|
||||
if strings.ToLower(message.User) == b.username {
|
||||
b.logger.Info("Twitch bot left channel", zap.String("channel", message.Channel))
|
||||
} else {
|
||||
b.logger.Debug("User left channel", zap.String("channel", message.Channel), zap.String("username", message.User))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) onMessageHandler(message irc.PrivateMessage) {
|
||||
for _, handler := range b.OnMessage.Items() {
|
||||
if handler != nil {
|
||||
handler.HandleBotMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore messages for a while or twitch will get mad!
|
||||
if time.Now().Before(b.lastMessage.Get().Add(time.Second * time.Duration(b.Config.CommandCooldown))) {
|
||||
b.logger.Debug("Message received too soon, ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
lowercaseMessage := strings.TrimSpace(strings.ToLower(message.Message))
|
||||
|
||||
// Check if it's a command
|
||||
if strings.HasPrefix(lowercaseMessage, "!") {
|
||||
// Run through supported commands
|
||||
for cmd, data := range b.commands.Copy() {
|
||||
if !data.Enabled {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(lowercaseMessage, cmd) {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(lowercaseMessage, " ", 2)
|
||||
if parts[0] != cmd {
|
||||
continue
|
||||
}
|
||||
go data.Handler(b, message)
|
||||
b.lastMessage.Set(time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// Run through custom commands
|
||||
for cmd, data := range b.customCommands.Copy() {
|
||||
if !data.Enabled {
|
||||
continue
|
||||
}
|
||||
lc := strings.ToLower(cmd)
|
||||
if !strings.HasPrefix(lowercaseMessage, lc) {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(lowercaseMessage, " ", 2)
|
||||
if parts[0] != lc {
|
||||
continue
|
||||
}
|
||||
go cmdCustom(b, cmd, data, message)
|
||||
b.lastMessage.Set(time.Now())
|
||||
}
|
||||
|
||||
err := b.api.db.PutJSON(ChatEventKey, message)
|
||||
if err != nil {
|
||||
b.logger.Warn("Could not save chat message to key", zap.String("key", ChatEventKey), zap.Error(err))
|
||||
}
|
||||
if b.Config.ChatHistory > 0 {
|
||||
history := b.chatHistory.Get()
|
||||
if len(history) >= b.Config.ChatHistory {
|
||||
history = history[len(history)-b.Config.ChatHistory+1:]
|
||||
}
|
||||
b.chatHistory.Set(append(history, message))
|
||||
err = b.api.db.PutJSON(ChatHistoryKey, b.chatHistory.Get())
|
||||
if err != nil {
|
||||
b.logger.Warn("Could not save message to chat history", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if b.Timers != nil {
|
||||
go b.Timers.OnMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) onConnectHandler() {
|
||||
for _, handler := range b.OnConnect.Items() {
|
||||
if handler != nil {
|
||||
handler.HandleBotConnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) Close() error {
|
||||
if b.cancelUpdateSub != nil {
|
||||
b.cancelUpdateSub()
|
||||
}
|
||||
if b.cancelWriteRPCSub != nil {
|
||||
b.cancelWriteRPCSub()
|
||||
}
|
||||
if b.cancelWritePlainRPCSub != nil {
|
||||
b.cancelWritePlainRPCSub()
|
||||
}
|
||||
if b.Timers != nil {
|
||||
b.Timers.Close()
|
||||
}
|
||||
if b.Alerts != nil {
|
||||
b.Alerts.Close()
|
||||
}
|
||||
return b.Client.Disconnect()
|
||||
}
|
||||
|
||||
func (b *Bot) updateCommands(value string) {
|
||||
err := utils.LoadJSONToWrapped[map[string]BotCustomCommand](value, b.customCommands)
|
||||
if err != nil {
|
||||
b.logger.Error("Failed to decode new custom commands", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Recreate templates
|
||||
if err := b.updateTemplates(); err != nil {
|
||||
b.logger.Error("Failed to update custom commands templates", zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleWritePlainMessageRPC(value string) {
|
||||
b.Client.Say(b.Config.Channel, value)
|
||||
}
|
||||
|
||||
func (b *Bot) handleWriteMessageRPC(value string) {
|
||||
var request WriteMessageRequest
|
||||
if err := json.Unmarshal([]byte(value), &request); err != nil {
|
||||
b.logger.Warn("Failed to decode write message request", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if request.ReplyTo != nil && *request.ReplyTo != "" {
|
||||
b.Client.Reply(b.Config.Channel, *request.ReplyTo, request.Message)
|
||||
return
|
||||
}
|
||||
if request.WhisperTo != nil && *request.WhisperTo != "" {
|
||||
client, err := b.api.GetUserClient(false)
|
||||
if err != nil {
|
||||
b.logger.Error("Failed to retrieve client", zap.Error(err))
|
||||
return
|
||||
}
|
||||
reply, err := client.SendUserWhisper(&helix.SendUserWhisperParams{
|
||||
FromUserID: b.api.User.ID,
|
||||
ToUserID: *request.WhisperTo,
|
||||
Message: request.Message,
|
||||
})
|
||||
if reply.Error != "" {
|
||||
b.logger.Error("Failed to send whisper", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
|
||||
}
|
||||
if err != nil {
|
||||
b.logger.Error("Failed to send whisper", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if request.Announce {
|
||||
client, err := b.api.GetUserClient(false)
|
||||
if err != nil {
|
||||
b.logger.Error("Failed to retrieve client", zap.Error(err))
|
||||
return
|
||||
}
|
||||
reply, err := client.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
|
||||
BroadcasterID: b.api.User.ID,
|
||||
ModeratorID: b.api.User.ID,
|
||||
Message: request.Message,
|
||||
})
|
||||
if reply.Error != "" {
|
||||
b.logger.Error("Failed to send announcement", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
|
||||
}
|
||||
if err != nil {
|
||||
b.logger.Error("Failed to send announcement", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
b.Client.Say(b.Config.Channel, request.Message)
|
||||
}
|
||||
|
||||
func (b *Bot) updateTemplates() error {
|
||||
b.customTemplates.Set(make(map[string]*template.Template))
|
||||
for cmd, tmpl := range b.customCommands.Copy() {
|
||||
tpl, err := b.MakeTemplate(tmpl.Response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.customTemplates.SetKey(cmd, tpl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) Connect() {
|
||||
err := b.Client.Connect()
|
||||
if err != nil {
|
||||
if errors.Is(err, irc.ErrClientDisconnected) {
|
||||
b.logger.Info("Twitch bot connection terminated", zap.Error(err))
|
||||
} else {
|
||||
b.logger.Error("Twitch bot connection terminated unexpectedly", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) WriteMessage(message string) {
|
||||
b.Client.Say(b.Config.Channel, message)
|
||||
}
|
||||
|
||||
func (b *Bot) RegisterCommand(trigger string, command BotCommand) {
|
||||
b.commands.SetKey(trigger, command)
|
||||
}
|
||||
|
||||
func (b *Bot) RemoveCommand(trigger string) {
|
||||
b.commands.DeleteKey(trigger)
|
||||
}
|
||||
|
||||
func getUserAccessLevel(user irc.User) AccessLevelType {
|
||||
// Check broadcaster
|
||||
if _, ok := user.Badges["broadcaster"]; ok {
|
||||
return ALTStreamer
|
||||
}
|
||||
|
||||
// Check mods
|
||||
if _, ok := user.Badges["moderator"]; ok {
|
||||
return ALTModerators
|
||||
}
|
||||
|
||||
// Check VIP
|
||||
if _, ok := user.Badges["vip"]; ok {
|
||||
return ALTVIP
|
||||
}
|
||||
|
||||
// Check subscribers
|
||||
if _, ok := user.Badges["subscriber"]; ok {
|
||||
return ALTSubscribers
|
||||
}
|
||||
|
||||
return ALTEveryone
|
||||
}
|
||||
|
||||
func defaultBotConfig() BotConfig {
|
||||
return BotConfig{
|
||||
CommandCooldown: 2,
|
||||
}
|
||||
}
|
|
@ -1,38 +1,56 @@
|
|||
package timers
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~ashkeel/containers/sync"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
irc "github.com/gempir/go-twitch-irc/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
|
||||
)
|
||||
|
||||
var json = jsoniter.ConfigFastest
|
||||
const BotTimersKey = "twitch/bot-modules/timers/config"
|
||||
|
||||
type BotTimersConfig struct {
|
||||
Timers map[string]BotTimer `json:"timers" desc:"List of timers as a dictionary"`
|
||||
}
|
||||
|
||||
type BotTimer struct {
|
||||
// Whether the timer is enabled
|
||||
Enabled bool `json:"enabled" desc:"Enable the timer"`
|
||||
|
||||
// Timer name (must be unique)
|
||||
Name string `json:"name" desc:"Timer name (must be unique)"`
|
||||
|
||||
// Minimum chat messages in the last 5 minutes for timer to trigger
|
||||
MinimumChatActivity int `json:"minimum_chat_activity" desc:"Minimum chat messages in the last 5 minutes for timer to trigger"`
|
||||
|
||||
// Minimum amount of time (in seconds) that needs to pass before it triggers again
|
||||
MinimumDelay int `json:"minimum_delay" desc:"Minimum amount of time (in seconds) that needs to pass before it triggers again"`
|
||||
|
||||
// Messages to write (randomly chosen)
|
||||
Messages []string `json:"messages" desc:"Messages to write (randomly chosen)"`
|
||||
}
|
||||
|
||||
const AverageMessageWindow = 5
|
||||
|
||||
type Module struct {
|
||||
Config Config
|
||||
type BotTimerModule struct {
|
||||
Config BotTimersConfig
|
||||
|
||||
bot *Bot
|
||||
lastTrigger *sync.Map[string, time.Time]
|
||||
messages *sync.Slice[int]
|
||||
|
||||
logger *zap.Logger
|
||||
db database.Database
|
||||
cancelTimerSub database.CancelFunc
|
||||
}
|
||||
|
||||
func Setup(db database.Database, logger *zap.Logger) *Module {
|
||||
mod := &Module{
|
||||
func SetupTimers(bot *Bot) *BotTimerModule {
|
||||
mod := &BotTimerModule{
|
||||
bot: bot,
|
||||
lastTrigger: sync.NewMap[string, time.Time](),
|
||||
messages: sync.NewSlice[int](),
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Fill messages with zero values
|
||||
|
@ -42,32 +60,32 @@ func Setup(db database.Database, logger *zap.Logger) *Module {
|
|||
}
|
||||
|
||||
// Load config from database
|
||||
err := db.GetJSON(ConfigKey, &mod.Config)
|
||||
err := bot.api.db.GetJSON(BotTimersKey, &mod.Config)
|
||||
if err != nil {
|
||||
logger.Debug("Config load error", zap.Error(err))
|
||||
mod.Config = Config{
|
||||
Timers: make(map[string]ChatTimer),
|
||||
bot.logger.Debug("Config load error", zap.Error(err))
|
||||
mod.Config = BotTimersConfig{
|
||||
Timers: make(map[string]BotTimer),
|
||||
}
|
||||
// Save empty config
|
||||
err = db.PutJSON(ConfigKey, mod.Config)
|
||||
err = bot.api.db.PutJSON(BotTimersKey, mod.Config)
|
||||
if err != nil {
|
||||
logger.Warn("Could not save default config for bot timers", zap.Error(err))
|
||||
bot.logger.Warn("Could not save default config for bot timers", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
mod.cancelTimerSub, err = db.SubscribeKey(ConfigKey, func(value string) {
|
||||
mod.cancelTimerSub, err = bot.api.db.SubscribeKey(BotTimersKey, func(value string) {
|
||||
err := json.UnmarshalFromString(value, &mod.Config)
|
||||
if err != nil {
|
||||
logger.Debug("Error reloading timer config", zap.Error(err))
|
||||
bot.logger.Debug("Error reloading timer config", zap.Error(err))
|
||||
} else {
|
||||
logger.Info("Reloaded timer config")
|
||||
bot.logger.Info("Reloaded timer config")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Could not set-up timer reload subscription", zap.Error(err))
|
||||
bot.logger.Error("Could not set-up timer reload subscription", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Debug("Loaded timers", zap.Int("timers", len(mod.Config.Timers)))
|
||||
bot.logger.Debug("Loaded timers", zap.Int("timers", len(mod.Config.Timers)))
|
||||
|
||||
// Start goroutine for clearing message counters and running timers
|
||||
go mod.runTimers()
|
||||
|
@ -75,7 +93,7 @@ func Setup(db database.Database, logger *zap.Logger) *Module {
|
|||
return mod
|
||||
}
|
||||
|
||||
func (m *Module) runTimers() {
|
||||
func (m *BotTimerModule) runTimers() {
|
||||
for {
|
||||
// Wait until next tick (remainder until next minute, as close to 0 seconds as possible)
|
||||
currentTime := time.Now()
|
||||
|
@ -83,9 +101,9 @@ func (m *Module) runTimers() {
|
|||
timeUntilNextTick := nextTick.Sub(currentTime)
|
||||
time.Sleep(timeUntilNextTick)
|
||||
|
||||
err := m.db.PutJSON(chat.ActivityKey, m.messages.Get())
|
||||
err := m.bot.api.db.PutJSON(ChatActivityKey, m.messages.Get())
|
||||
if err != nil {
|
||||
m.logger.Warn("Error saving chat activity", zap.Error(err))
|
||||
m.bot.logger.Warn("Error saving chat activity", zap.Error(err))
|
||||
}
|
||||
|
||||
// Calculate activity
|
||||
|
@ -104,7 +122,7 @@ func (m *Module) runTimers() {
|
|||
}
|
||||
}
|
||||
|
||||
func (m *Module) ProcessTimer(name string, timer ChatTimer, activity int) {
|
||||
func (m *BotTimerModule) ProcessTimer(name string, timer BotTimer, activity int) {
|
||||
// Must be enabled
|
||||
if !timer.Enabled {
|
||||
return
|
||||
|
@ -137,22 +155,19 @@ func (m *Module) ProcessTimer(name string, timer ChatTimer, activity int) {
|
|||
message := timer.Messages[rand.Intn(len(timer.Messages))]
|
||||
|
||||
// Write message to chat
|
||||
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
|
||||
Message: message,
|
||||
Announce: timer.Announce,
|
||||
})
|
||||
m.bot.WriteMessage(message)
|
||||
|
||||
// Update last trigger
|
||||
m.lastTrigger.SetKey(name, now)
|
||||
}
|
||||
|
||||
func (m *Module) Close() {
|
||||
func (m *BotTimerModule) Close() {
|
||||
if m.cancelTimerSub != nil {
|
||||
m.cancelTimerSub()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) currentChatActivity() int {
|
||||
func (m *BotTimerModule) currentChatActivity() int {
|
||||
total := 0
|
||||
for _, v := range m.messages.Get() {
|
||||
total += v
|
||||
|
@ -160,7 +175,7 @@ func (m *Module) currentChatActivity() int {
|
|||
return total
|
||||
}
|
||||
|
||||
func (m *Module) OnMessage() {
|
||||
index := time.Now().Minute() % AverageMessageWindow
|
||||
func (m *BotTimerModule) OnMessage(message irc.PrivateMessage) {
|
||||
index := message.Time.Minute() % AverageMessageWindow
|
||||
m.messages.SetIndex(index, 1)
|
||||
}
|
|
@ -1,104 +0,0 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var accessLevels = map[AccessLevelType]int{
|
||||
ALTEveryone: 0,
|
||||
ALTSubscribers: 1,
|
||||
ALTVIP: 2,
|
||||
ALTModerators: 3,
|
||||
ALTStreamer: 999,
|
||||
}
|
||||
|
||||
type CommandHandler func(message helix.EventSubChannelChatMessageEvent)
|
||||
|
||||
type Command struct {
|
||||
Description string
|
||||
Usage string
|
||||
AccessLevel AccessLevelType
|
||||
Handler CommandHandler
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func getUserAccessLevel(badges []helix.EventSubChatBadge) AccessLevelType {
|
||||
// Read badges
|
||||
var broadcaster, moderator, vip, subscriber bool
|
||||
for _, badge := range badges {
|
||||
switch badge.SetID {
|
||||
case "broadcaster":
|
||||
broadcaster = true
|
||||
case "moderator":
|
||||
moderator = true
|
||||
case "vip":
|
||||
vip = true
|
||||
case "subscriber":
|
||||
subscriber = true
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case broadcaster:
|
||||
return ALTStreamer
|
||||
case moderator:
|
||||
return ALTModerators
|
||||
case vip:
|
||||
return ALTVIP
|
||||
case subscriber:
|
||||
return ALTSubscribers
|
||||
default:
|
||||
return ALTEveryone
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCustom(mod *Module, cmd string, data CustomCommand, message helix.EventSubChannelChatMessageEvent) {
|
||||
// Check access level
|
||||
accessLevel := getUserAccessLevel(message.Badges)
|
||||
|
||||
// Ensure that access level is high enough
|
||||
if accessLevels[accessLevel] < accessLevels[data.AccessLevel] {
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tpl, ok := mod.customTemplates.GetKey(cmd)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := tpl.Execute(&buf, message); err != nil {
|
||||
mod.logger.Error("Failed to execute custom command template", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
var request WriteMessageRequest
|
||||
|
||||
switch data.ResponseType {
|
||||
case ResponseTypeDefault, ResponseTypeChat:
|
||||
request = WriteMessageRequest{
|
||||
Message: buf.String(),
|
||||
}
|
||||
case ResponseTypeReply:
|
||||
request = WriteMessageRequest{
|
||||
Message: buf.String(),
|
||||
ReplyTo: message.MessageID,
|
||||
}
|
||||
case ResponseTypeWhisper:
|
||||
request = WriteMessageRequest{
|
||||
Message: buf.String(),
|
||||
WhisperTo: message.ChatterUserID,
|
||||
}
|
||||
case ResponseTypeAnnounce:
|
||||
request = WriteMessageRequest{
|
||||
Message: buf.String(),
|
||||
Announce: true,
|
||||
}
|
||||
default:
|
||||
mod.logger.Error("Unknown response type", zap.String("type", string(data.ResponseType)))
|
||||
}
|
||||
|
||||
WriteMessage(mod.db, mod.logger, request)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
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"`
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
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"
|
||||
)
|
||||
|
||||
type ResponseType string
|
||||
|
||||
const (
|
||||
ResponseTypeDefault ResponseType = ""
|
||||
ResponseTypeChat ResponseType = "chat"
|
||||
ResponseTypeWhisper ResponseType = "whisper"
|
||||
ResponseTypeReply ResponseType = "reply"
|
||||
ResponseTypeAnnounce ResponseType = "announce"
|
||||
)
|
||||
|
||||
type AccessLevelType string
|
||||
|
||||
const (
|
||||
ALTEveryone AccessLevelType = "everyone"
|
||||
ALTSubscribers AccessLevelType = "subscriber"
|
||||
ALTVIP AccessLevelType = "vip"
|
||||
ALTModerators AccessLevelType = "moderators"
|
||||
ALTStreamer AccessLevelType = "streamer"
|
||||
)
|
||||
|
||||
// CustomCommand is a definition of a custom command of the chatbot
|
||||
type CustomCommand struct {
|
||||
// Command description
|
||||
Description string `json:"description" desc:"Command description"`
|
||||
|
||||
// Minimum access level needed to use the command
|
||||
AccessLevel AccessLevelType `json:"access_level" desc:"Minimum access level needed to use the command"`
|
||||
|
||||
// Response template (in Go templating format)
|
||||
Response string `json:"response" desc:"Response template (in Go templating format)"`
|
||||
|
||||
// Is the command enabled?
|
||||
Enabled bool `json:"enabled" desc:"Is the command enabled?"`
|
||||
|
||||
// How to respond to the user
|
||||
ResponseType ResponseType `json:"response_type" desc:"How to respond to the user"`
|
||||
}
|
|
@ -1,394 +0,0 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch"
|
||||
|
||||
"git.sr.ht/~ashkeel/containers/sync"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/loyalty"
|
||||
)
|
||||
|
||||
const (
|
||||
commandRedeem = "!redeem"
|
||||
commandGoals = "!goals"
|
||||
commandBalance = "!balance"
|
||||
commandContribute = "!contribute"
|
||||
)
|
||||
|
||||
type loyaltyIntegration struct {
|
||||
ctx context.Context
|
||||
manager *loyalty.Manager
|
||||
module *Module
|
||||
|
||||
activeUsers *sync.Map[string, bool]
|
||||
}
|
||||
|
||||
func setupLoyaltyIntegration(ctx context.Context, mod *Module, manager *loyalty.Manager) *loyaltyIntegration {
|
||||
li := &loyaltyIntegration{
|
||||
ctx: ctx,
|
||||
manager: manager,
|
||||
module: mod,
|
||||
|
||||
activeUsers: sync.NewMap[string, bool](),
|
||||
}
|
||||
|
||||
// Add loyalty-based commands
|
||||
mod.commands.SetKey(commandRedeem, Command{
|
||||
Description: "Redeem a reward with loyalty points",
|
||||
Usage: fmt.Sprintf("%s <reward-id> [request text]", commandRedeem),
|
||||
AccessLevel: ALTEveryone,
|
||||
Handler: li.cmdRedeemReward,
|
||||
Enabled: true,
|
||||
})
|
||||
mod.commands.SetKey(commandBalance, Command{
|
||||
Description: "See your current point balance",
|
||||
Usage: commandBalance,
|
||||
AccessLevel: ALTEveryone,
|
||||
Handler: li.cmdBalance,
|
||||
Enabled: true,
|
||||
})
|
||||
mod.commands.SetKey(commandGoals, Command{
|
||||
Description: "Check currently active community goals",
|
||||
Usage: commandGoals,
|
||||
AccessLevel: ALTEveryone,
|
||||
Handler: li.cmdGoalList,
|
||||
Enabled: true,
|
||||
})
|
||||
mod.commands.SetKey(commandContribute, Command{
|
||||
Description: "Contribute points to a community goal",
|
||||
Usage: fmt.Sprintf("%s <points> [<goal-id>]", commandContribute),
|
||||
AccessLevel: ALTEveryone,
|
||||
Handler: li.cmdContributeGoal,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
// Setup handler for adding points over time
|
||||
go func() {
|
||||
config := li.manager.Config.Get()
|
||||
// Stop handler if loyalty system is disabled or there is no valid point interval
|
||||
if !config.Enabled || config.Points.Interval <= 0 {
|
||||
return
|
||||
}
|
||||
for {
|
||||
// Wait for next poll
|
||||
select {
|
||||
case <-li.ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Duration(config.Points.Interval) * time.Second):
|
||||
}
|
||||
|
||||
// If stream is confirmed offline, don't give points away!
|
||||
var streamInfos []helix.Stream
|
||||
err := mod.db.GetJSON(twitch.StreamInfoKey, &streamInfos)
|
||||
if err != nil {
|
||||
mod.logger.Error("Error retrieving stream info", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if len(streamInfos) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get user list
|
||||
cursor := ""
|
||||
var users []string
|
||||
for {
|
||||
userClient, err := twitch.GetUserClient(mod.db, false)
|
||||
if err != nil {
|
||||
mod.logger.Error("Could not get user api client for list of chatters", zap.Error(err))
|
||||
return
|
||||
}
|
||||
res, err := userClient.GetChannelChatChatters(&helix.GetChatChattersParams{
|
||||
BroadcasterID: mod.user.ID,
|
||||
ModeratorID: mod.user.ID,
|
||||
First: "1000",
|
||||
After: cursor,
|
||||
})
|
||||
if err != nil {
|
||||
mod.logger.Error("Could not retrieve list of chatters", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, user := range res.Data.Chatters {
|
||||
users = append(users, user.UserLogin)
|
||||
}
|
||||
cursor = res.Data.Pagination.Cursor
|
||||
if cursor == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate for each user in the list
|
||||
pointsToGive := make(map[string]int64)
|
||||
for _, user := range users {
|
||||
// Check if user is blocked
|
||||
if li.manager.IsBanned(user) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if user was active (chatting) for the bonus dingus
|
||||
award := config.Points.Amount
|
||||
if li.IsActive(user) {
|
||||
award += config.Points.ActivityBonus
|
||||
}
|
||||
|
||||
// Add to point pool if already on it, otherwise initialize
|
||||
pointsToGive[user] = award
|
||||
}
|
||||
|
||||
li.ResetActivity()
|
||||
|
||||
// If changes were made, save the pool!
|
||||
if len(users) > 0 {
|
||||
err := li.manager.GivePoints(pointsToGive)
|
||||
if err != nil {
|
||||
mod.logger.Error("Error awarding loyalty points to user", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
mod.logger.Info("Loyalty system integration with Twitch is ready")
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
func (li *loyaltyIntegration) Close() {
|
||||
li.module.commands.DeleteKey(commandRedeem)
|
||||
li.module.commands.DeleteKey(commandBalance)
|
||||
li.module.commands.DeleteKey(commandGoals)
|
||||
li.module.commands.DeleteKey(commandContribute)
|
||||
}
|
||||
|
||||
func (li *loyaltyIntegration) HandleMessage(message helix.EventSubChannelChatMessageEvent) {
|
||||
li.activeUsers.SetKey(message.ChatterUserLogin, true)
|
||||
}
|
||||
|
||||
func (li *loyaltyIntegration) IsActive(user string) bool {
|
||||
active, ok := li.activeUsers.GetKey(user)
|
||||
return ok && active
|
||||
}
|
||||
|
||||
func (li *loyaltyIntegration) ResetActivity() {
|
||||
li.activeUsers = sync.NewMap[string, bool]()
|
||||
}
|
||||
|
||||
func (li *loyaltyIntegration) cmdBalance(message helix.EventSubChannelChatMessageEvent) {
|
||||
// Get user balance
|
||||
balance := li.manager.GetPoints(message.ChatterUserLogin)
|
||||
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("You have %d %s!", balance, li.manager.Config.Get().Currency),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
}
|
||||
|
||||
func (li *loyaltyIntegration) cmdRedeemReward(message helix.EventSubChannelChatMessageEvent) {
|
||||
parts := strings.Fields(message.Message.Text)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
redeemID := parts[1]
|
||||
|
||||
// Find reward
|
||||
reward := li.manager.GetReward(redeemID)
|
||||
if reward.ID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Reward not active, return early
|
||||
if !reward.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Get user balance
|
||||
balance := li.manager.GetPoints(message.ChatterUserLogin)
|
||||
config := li.manager.Config.Get()
|
||||
|
||||
// Check if user can afford the reward
|
||||
if balance-reward.Price < 0 {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("I'm sorry but you cannot afford this (have %d %s, need %d)", balance, config.Currency, reward.Price),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
text := ""
|
||||
if len(parts) > 2 {
|
||||
text = strings.Join(parts[2:], " ")
|
||||
}
|
||||
|
||||
// Perform redeem
|
||||
if err := li.manager.PerformRedeem(loyalty.Redeem{
|
||||
Username: message.ChatterUserLogin,
|
||||
DisplayName: message.ChatterUserName,
|
||||
When: time.Now(),
|
||||
Reward: reward,
|
||||
RequestText: text,
|
||||
}); err != nil {
|
||||
if errors.Is(err, loyalty.ErrRedeemInCooldown) {
|
||||
nextAvailable := li.manager.GetRewardCooldown(reward.ID)
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("That reward is in cooldown (available in %s)",
|
||||
time.Until(nextAvailable).Truncate(time.Second)),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
li.module.logger.Error("Error while performing redeem", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("HolidayPresent %s has redeemed %s! (new balance: %d %s)",
|
||||
message.ChatterUserName, reward.Name, li.manager.GetPoints(message.ChatterUserLogin), config.Currency),
|
||||
})
|
||||
}
|
||||
|
||||
func (li *loyaltyIntegration) cmdGoalList(message helix.EventSubChannelChatMessageEvent) {
|
||||
goals := li.manager.Goals.Get()
|
||||
if len(goals) < 1 {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("There are no active community goals right now :(!"),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
msg := "Current goals: "
|
||||
for _, goal := range goals {
|
||||
if !goal.Enabled {
|
||||
continue
|
||||
}
|
||||
msg += fmt.Sprintf("%s (%d/%d %s) [id: %s] | ", goal.Name, goal.Contributed, goal.TotalGoal, li.manager.Config.Get().Currency, goal.ID)
|
||||
}
|
||||
msg += " Contribute with <!contribute POINTS GOALID>"
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
func (li *loyaltyIntegration) cmdContributeGoal(message helix.EventSubChannelChatMessageEvent) {
|
||||
goals := li.manager.Goals.Get()
|
||||
|
||||
// Set defaults if user doesn't provide them
|
||||
points := int64(100)
|
||||
goalIndex := -1
|
||||
hasGoals := false
|
||||
|
||||
// Get first unreached goal for default
|
||||
for index, goal := range goals {
|
||||
if !goal.Enabled {
|
||||
continue
|
||||
}
|
||||
hasGoals = true
|
||||
if goal.Contributed < goal.TotalGoal {
|
||||
goalIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Do we not have any goal we can contribute to? Hooray I guess?
|
||||
if goalIndex < 0 {
|
||||
if hasGoals {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("All active community goals have been reached already! NewRecord"),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
} else {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("There are no active community goals right now :(!"),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse parameters if provided
|
||||
parts := strings.Fields(message.Message.Text)
|
||||
if len(parts) > 1 {
|
||||
newPoints, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err == nil {
|
||||
if newPoints <= 0 {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("Nice try SoBayed"),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
points = newPoints
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
found := false
|
||||
goalID := parts[2]
|
||||
// Find Goal index
|
||||
for index, goal := range goals {
|
||||
if !goal.Enabled {
|
||||
continue
|
||||
}
|
||||
if goal.ID == goalID {
|
||||
goalIndex = index
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Invalid goal ID provided
|
||||
if !found {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("I couldn't find that goal ID :("),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get goal
|
||||
selectedGoal := goals[goalIndex]
|
||||
|
||||
// Check if goal was reached already
|
||||
if selectedGoal.Contributed >= selectedGoal.TotalGoal {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("This goal was already reached! ヾ(•ω•`)o"),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Add points to goal
|
||||
points, err := li.manager.PerformContribution(selectedGoal, message.ChatterUserLogin, points)
|
||||
if err != nil {
|
||||
li.module.logger.Error("Error while contributing to goal", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if points == 0 {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("Sorry but you're broke"),
|
||||
ReplyTo: message.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
selectedGoal = li.manager.Goals.Get()[goalIndex]
|
||||
config := li.manager.Config.Get()
|
||||
newRemaining := selectedGoal.TotalGoal - selectedGoal.Contributed
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("NewRecord %s contributed %d %s to \"%s\"!! Only %d %s left!", message.ChatterUserName, points, config.Currency, selectedGoal.Name, newRemaining, config.Currency),
|
||||
})
|
||||
|
||||
// Check if goal was reached!
|
||||
if newRemaining <= 0 {
|
||||
li.module.WriteMessage(WriteMessageRequest{
|
||||
Message: fmt.Sprintf("FallWinning The community goal \"%s\" was reached! FallWinning", selectedGoal.Name),
|
||||
Announce: true,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,270 +0,0 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
textTemplate "text/template"
|
||||
"time"
|
||||
|
||||
"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/eventsub"
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch/template"
|
||||
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||
)
|
||||
|
||||
var json = jsoniter.ConfigFastest
|
||||
|
||||
type Module struct {
|
||||
Config Config
|
||||
|
||||
ctx context.Context
|
||||
db *database.LocalDBClient
|
||||
api *helix.Client
|
||||
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]
|
||||
customTemplates *sync.Map[string, *textTemplate.Template]
|
||||
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)
|
||||
|
||||
mod := &Module{
|
||||
ctx: newContext,
|
||||
db: db,
|
||||
api: api,
|
||||
user: user,
|
||||
logger: logger,
|
||||
templater: templater,
|
||||
lastMessage: sync.NewRWSync(time.Now()),
|
||||
|
||||
commands: sync.NewMap[string, Command](),
|
||||
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 {
|
||||
logger.Error("Failed to load chat module config", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// Load custom commands
|
||||
var customCommands map[string]CustomCommand
|
||||
err = db.GetJSON(CustomCommandsKey, &customCommands)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrEmptyKey) {
|
||||
customCommands = make(map[string]CustomCommand)
|
||||
} else {
|
||||
logger.Error("Failed to load custom commands", zap.Error(err))
|
||||
}
|
||||
}
|
||||
mod.customCommands.Set(customCommands)
|
||||
|
||||
err = mod.updateTemplates()
|
||||
if err != nil {
|
||||
logger.Error("Failed to parse custom commands", zap.Error(err))
|
||||
}
|
||||
mod.cancelUpdateSub, err = db.SubscribeKey(CustomCommandsKey, mod.updateCommands)
|
||||
if 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 {
|
||||
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 {
|
||||
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))
|
||||
|
||||
// Check if it's a command
|
||||
if strings.HasPrefix(lowercaseMessage, "!") {
|
||||
// Run through supported commands
|
||||
for cmd, data := range mod.commands.Copy() {
|
||||
if !data.Enabled {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(lowercaseMessage, cmd) {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(lowercaseMessage, " ", 2)
|
||||
if parts[0] != cmd {
|
||||
continue
|
||||
}
|
||||
go data.Handler(chatMessage)
|
||||
mod.lastMessage.Set(time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// Run through custom commands
|
||||
for cmd, data := range mod.customCommands.Copy() {
|
||||
if !data.Enabled {
|
||||
continue
|
||||
}
|
||||
lc := strings.ToLower(cmd)
|
||||
if !strings.HasPrefix(lowercaseMessage, lc) {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(lowercaseMessage, " ", 2)
|
||||
if parts[0] != lc {
|
||||
continue
|
||||
}
|
||||
go cmdCustom(mod, cmd, data, chatMessage)
|
||||
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) {
|
||||
var request WriteMessageRequest
|
||||
if err := json.Unmarshal([]byte(value), &request); err != nil {
|
||||
mod.logger.Warn("Failed to decode write message request", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if request.Announce {
|
||||
resp, err := mod.api.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
|
||||
BroadcasterID: mod.user.ID,
|
||||
ModeratorID: mod.user.ID,
|
||||
Message: request.Message,
|
||||
})
|
||||
if err != nil {
|
||||
mod.logger.Error("Failed to send announcement", zap.Error(err))
|
||||
}
|
||||
if resp.Error != "" {
|
||||
mod.logger.Error("Failed to send announcement", zap.String("code", resp.Error), zap.String("message", resp.ErrorMessage))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if request.WhisperTo != "" {
|
||||
resp, err := mod.api.SendUserWhisper(&helix.SendUserWhisperParams{
|
||||
FromUserID: mod.user.ID,
|
||||
ToUserID: request.WhisperTo,
|
||||
Message: request.Message,
|
||||
})
|
||||
if err != nil {
|
||||
mod.logger.Error("Failed to send whisper", zap.Error(err))
|
||||
}
|
||||
if resp.Error != "" {
|
||||
mod.logger.Error("Failed to send whisper", zap.String("code", resp.Error), zap.String("message", resp.ErrorMessage))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := mod.api.SendChatMessage(&helix.SendChatMessageParams{
|
||||
BroadcasterID: mod.user.ID,
|
||||
SenderID: mod.user.ID,
|
||||
Message: request.Message,
|
||||
ReplyParentMessageID: request.ReplyTo,
|
||||
})
|
||||
if err != nil {
|
||||
mod.logger.Error("Failed to send chat message", zap.Error(err))
|
||||
}
|
||||
if resp.Error != "" {
|
||||
mod.logger.Error("Failed to send chat message", zap.String("code", resp.Error), zap.String("message", resp.ErrorMessage))
|
||||
}
|
||||
}
|
||||
|
||||
func (mod *Module) updateCommands(value string) {
|
||||
err := utils.LoadJSONToWrapped[map[string]CustomCommand](value, mod.customCommands)
|
||||
if err != nil {
|
||||
mod.logger.Error("Failed to decode new custom commands", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Recreate templates
|
||||
if err := mod.updateTemplates(); err != nil {
|
||||
mod.logger.Error("Failed to update custom commands templates", zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (mod *Module) updateTemplates() error {
|
||||
mod.customTemplates.Set(make(map[string]*textTemplate.Template))
|
||||
for cmd, tmpl := range mod.customCommands.Copy() {
|
||||
tpl, err := mod.templater.MakeTemplate(tmpl.Response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mod.customTemplates.SetKey(cmd, tpl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mod *Module) WriteMessage(request WriteMessageRequest) {
|
||||
WriteMessage(mod.db, mod.logger, request)
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WriteMessageRequest is an RPC to send a chat message with extra options
|
||||
type WriteMessageRequest struct {
|
||||
Message string `json:"message" desc:"Chat message to send"`
|
||||
ReplyTo string `json:"reply_to,omitempty" desc:"If specified, send as reply to a message ID"`
|
||||
WhisperTo string `json:"whisper_to,omitempty" desc:"If specified, send as whisper to user ID"`
|
||||
Announce bool `json:"announce" desc:"If true, send as announcement"`
|
||||
}
|
||||
|
||||
func WriteMessage(db database.Database, logger *zap.Logger, m WriteMessageRequest) {
|
||||
err := db.PutJSON(WriteMessageRPC, m)
|
||||
if err != nil {
|
||||
logger.Error("Failed to write chat message", zap.Error(err))
|
||||
}
|
||||
}
|
162
twitch/client.auth.go
Normal file
162
twitch/client.auth.go
Normal file
|
@ -0,0 +1,162 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
)
|
||||
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope []string `json:"scope"`
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
var scopes = []string{
|
||||
"bits:read",
|
||||
"channel:bot",
|
||||
"channel:moderate",
|
||||
"channel:read:hype_train",
|
||||
"channel:read:polls",
|
||||
"channel:read:predictions",
|
||||
"channel:read:redemptions",
|
||||
"channel:read:subscriptions",
|
||||
"chat:edit",
|
||||
"chat:read",
|
||||
"moderator:manage:announcements",
|
||||
"moderator:read:chatters",
|
||||
"moderator:read:followers",
|
||||
"user_read",
|
||||
"user:bot",
|
||||
"user:manage:whispers",
|
||||
"user:read:chat",
|
||||
"whispers:edit",
|
||||
"whispers:read",
|
||||
}
|
||||
|
||||
func (c *Client) GetAuthorizationURL() string {
|
||||
if c.API == nil {
|
||||
return "twitch-not-configured"
|
||||
}
|
||||
return c.API.GetAuthorizationURL(&helix.AuthorizationURLParams{
|
||||
ResponseType: "code",
|
||||
Scopes: scopes,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckScopes checks if the user has authorized all required scopes
|
||||
// Normally this would be the case but between versions strimertul has changed
|
||||
// the required scopes, and it's possible that some users have not re-authorized
|
||||
// the application with the new scopes.
|
||||
func (c *Client) CheckScopes() (bool, error) {
|
||||
var authResp AuthResponse
|
||||
if err := c.db.GetJSON(AuthKey, &authResp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Sort scopes for comparison
|
||||
slices.Sort(authResp.Scope)
|
||||
|
||||
return slices.Equal(scopes, authResp.Scope), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetUserClient(forceRefresh bool) (*helix.Client, error) {
|
||||
var authResp AuthResponse
|
||||
if err := c.db.GetJSON(AuthKey, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Handle token expiration
|
||||
if forceRefresh || time.Now().After(authResp.Time.Add(time.Duration(authResp.ExpiresIn)*time.Second)) {
|
||||
// Refresh tokens
|
||||
refreshed, err := c.API.RefreshUserAccessToken(authResp.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authResp.AccessToken = refreshed.Data.AccessToken
|
||||
authResp.RefreshToken = refreshed.Data.RefreshToken
|
||||
authResp.Time = time.Now().Add(time.Duration(refreshed.Data.ExpiresIn) * time.Second)
|
||||
|
||||
// Save new token pair
|
||||
err = c.db.PutJSON(AuthKey, authResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
config := c.Config.Get()
|
||||
return helix.NewClient(&helix.Options{
|
||||
ClientID: config.APIClientID,
|
||||
ClientSecret: config.APIClientSecret,
|
||||
UserAccessToken: authResp.AccessToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) GetLoggedUser() (helix.User, error) {
|
||||
if c.User.ID != "" {
|
||||
return c.User, nil
|
||||
}
|
||||
|
||||
client, err := c.GetUserClient(false)
|
||||
if err != nil {
|
||||
return helix.User{}, fmt.Errorf("failed getting API client for user: %w", err)
|
||||
}
|
||||
|
||||
users, err := client.GetUsers(&helix.UsersParams{})
|
||||
if err != nil {
|
||||
return helix.User{}, fmt.Errorf("failed looking up user: %w", err)
|
||||
}
|
||||
if len(users.Data.Users) < 1 {
|
||||
return helix.User{}, fmt.Errorf("no users found")
|
||||
}
|
||||
c.User = users.Data.Users[0]
|
||||
|
||||
return c.User, nil
|
||||
}
|
||||
|
||||
func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Get code from params
|
||||
code := req.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
// TODO Nice error page
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for access/refresh tokens
|
||||
userTokenResponse, err := c.API.RequestUserAccessToken(code)
|
||||
if err != nil {
|
||||
http.Error(w, "failed auth token request: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.db.PutJSON(AuthKey, AuthResponse{
|
||||
AccessToken: userTokenResponse.Data.AccessToken,
|
||||
RefreshToken: userTokenResponse.Data.RefreshToken,
|
||||
ExpiresIn: userTokenResponse.Data.ExpiresIn,
|
||||
Scope: userTokenResponse.Data.Scopes,
|
||||
Time: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "error saving auth data for user: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, `<html><body><h2>All done, you can close me now!</h2><script>window.close();</script></body></html>`)
|
||||
}
|
||||
|
||||
type RefreshResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope []string `json:"scope"`
|
||||
}
|
||||
|
||||
func getRedirectURI(baseurl string) string {
|
||||
return fmt.Sprintf("http://%s/twitch/callback", baseurl)
|
||||
}
|
|
@ -1,62 +1,25 @@
|
|||
package eventsub
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
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/utils"
|
||||
)
|
||||
|
||||
var json = jsoniter.ConfigFastest
|
||||
|
||||
const websocketEndpoint = "wss://eventsub.wss.twitch.tv/ws"
|
||||
|
||||
type Client struct {
|
||||
ctx context.Context
|
||||
twitchAPI *helix.Client
|
||||
db database.Database
|
||||
logger *zap.Logger
|
||||
user helix.User
|
||||
|
||||
eventCache *lru.Cache[string, time.Time]
|
||||
savedSubscriptions map[string]bool
|
||||
}
|
||||
|
||||
func Setup(ctx context.Context, twitchAPI *helix.Client, user helix.User, db database.Database, logger *zap.Logger) (*Client, error) {
|
||||
eventCache, err := lru.New[string, time.Time](128)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create LRU cache for events: %w", err)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
ctx: ctx,
|
||||
twitchAPI: twitchAPI,
|
||||
db: db,
|
||||
logger: logger,
|
||||
user: user,
|
||||
eventCache: eventCache,
|
||||
savedSubscriptions: make(map[string]bool),
|
||||
}
|
||||
|
||||
go client.eventSubLoop()
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) eventSubLoop() {
|
||||
func (c *Client) eventSubLoop(userClient *helix.Client) {
|
||||
endpoint := websocketEndpoint
|
||||
var err error
|
||||
var connection *websocket.Conn
|
||||
for endpoint != "" {
|
||||
endpoint, connection, err = c.connectWebsocket(endpoint, connection)
|
||||
endpoint, connection, err = c.connectWebsocket(endpoint, connection, userClient)
|
||||
if err != nil {
|
||||
c.logger.Error("EventSub websocket read error", zap.Error(err))
|
||||
}
|
||||
|
@ -83,7 +46,7 @@ func readLoop(connection *websocket.Conn, recv chan<- []byte, wsErr chan<- error
|
|||
}
|
||||
}
|
||||
|
||||
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn) (string, *websocket.Conn, error) {
|
||||
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn, userClient *helix.Client) (string, *websocket.Conn, error) {
|
||||
connection, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
c.logger.Error("Could not establish a connection to the EventSub websocket", zap.Error(err))
|
||||
|
@ -106,21 +69,21 @@ func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn) (st
|
|||
case messageData = <-received:
|
||||
}
|
||||
|
||||
var wsMessage WebsocketMessage
|
||||
var wsMessage EventSubWebsocketMessage
|
||||
err = json.Unmarshal(messageData, &wsMessage)
|
||||
if err != nil {
|
||||
c.logger.Error("Error decoding EventSub message", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
reconnectURL, done, err := c.processMessage(wsMessage, oldConnection)
|
||||
reconnectURL, done, err := c.processMessage(wsMessage, oldConnection, userClient)
|
||||
if done {
|
||||
return reconnectURL, connection, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) processMessage(wsMessage WebsocketMessage, oldConnection *websocket.Conn) (string, bool, error) {
|
||||
func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnection *websocket.Conn, userClient *helix.Client) (string, bool, error) {
|
||||
switch wsMessage.Metadata.MessageType {
|
||||
case "session_keepalive":
|
||||
// Nothing to do
|
||||
|
@ -139,7 +102,7 @@ func (c *Client) processMessage(wsMessage WebsocketMessage, oldConnection *webso
|
|||
}
|
||||
|
||||
// Add subscription to websocket session
|
||||
err = c.addSubscriptionsForSession(welcomeData.Session.ID)
|
||||
err = c.addSubscriptionsForSession(userClient, welcomeData.Session.ID)
|
||||
if err != nil {
|
||||
c.logger.Error("Could not add subscriptions", zap.Error(err))
|
||||
break
|
||||
|
@ -162,7 +125,7 @@ func (c *Client) processMessage(wsMessage WebsocketMessage, oldConnection *webso
|
|||
return "", false, nil
|
||||
}
|
||||
|
||||
func (c *Client) processEvent(message WebsocketMessage) {
|
||||
func (c *Client) processEvent(message EventSubWebsocketMessage) {
|
||||
// Check if we processed this already
|
||||
if message.Metadata.MessageID != "" {
|
||||
if c.eventCache.Contains(message.Metadata.MessageID) {
|
||||
|
@ -180,30 +143,27 @@ func (c *Client) processEvent(message WebsocketMessage) {
|
|||
}
|
||||
notificationData.Date = time.Now()
|
||||
|
||||
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))
|
||||
err = c.db.PutJSON(EventSubEventKey, notificationData)
|
||||
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", EventSubEventKey), zap.Error(err))
|
||||
}
|
||||
|
||||
var archive []NotificationMessagePayload
|
||||
err = c.db.GetJSON(historyKey, &archive)
|
||||
err = c.db.GetJSON(EventSubHistoryKey, &archive)
|
||||
if err != nil {
|
||||
archive = []NotificationMessagePayload{}
|
||||
}
|
||||
archive = append(archive, notificationData)
|
||||
if len(archive) > HistorySize {
|
||||
archive = archive[len(archive)-HistorySize:]
|
||||
if len(archive) > EventSubHistorySize {
|
||||
archive = archive[len(archive)-EventSubHistorySize:]
|
||||
}
|
||||
err = c.db.PutJSON(historyKey, archive)
|
||||
err = c.db.PutJSON(EventSubHistoryKey, archive)
|
||||
if err != nil {
|
||||
c.logger.Error("Error storing event to database", zap.String("key", historyKey), zap.Error(err))
|
||||
c.logger.Error("Error storing event to database", zap.String("key", EventSubHistoryKey), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) addSubscriptionsForSession(session string) error {
|
||||
func (c *Client) addSubscriptionsForSession(userClient *helix.Client, session string) error {
|
||||
if c.savedSubscriptions[session] {
|
||||
// Already subscribed
|
||||
return nil
|
||||
|
@ -214,12 +174,12 @@ func (c *Client) addSubscriptionsForSession(session string) error {
|
|||
SessionID: session,
|
||||
}
|
||||
for topic, version := range subscriptionVersions {
|
||||
sub, err := c.twitchAPI.CreateEventSubSubscription(&helix.EventSubSubscription{
|
||||
sub, err := userClient.CreateEventSubSubscription(&helix.EventSubSubscription{
|
||||
Type: topic,
|
||||
Version: version,
|
||||
Status: "enabled",
|
||||
Transport: transport,
|
||||
Condition: topicCondition(topic, c.user.ID),
|
||||
Condition: topicCondition(topic, c.User.ID),
|
||||
})
|
||||
if sub.Error != "" || sub.ErrorMessage != "" {
|
||||
c.logger.Error("EventSub Subscription error", zap.String("topic", topic), zap.String("topic-version", version), zap.String("err", sub.Error), zap.String("message", sub.ErrorMessage))
|
||||
|
@ -235,24 +195,15 @@ func (c *Client) addSubscriptionsForSession(session string) error {
|
|||
|
||||
func topicCondition(topic string, id string) helix.EventSubCondition {
|
||||
switch topic {
|
||||
case helix.EventSubTypeChannelRaid:
|
||||
case "channel.raid":
|
||||
return helix.EventSubCondition{
|
||||
ToBroadcasterUserID: id,
|
||||
}
|
||||
case helix.EventSubTypeChannelFollow:
|
||||
case "channel.follow":
|
||||
return helix.EventSubCondition{
|
||||
BroadcasterUserID: id,
|
||||
ModeratorUserID: id,
|
||||
}
|
||||
case
|
||||
helix.EventSubTypeChannelChatMessage,
|
||||
helix.EventSubTypeChannelChatNotification:
|
||||
{
|
||||
return helix.EventSubCondition{
|
||||
BroadcasterUserID: id,
|
||||
UserID: id,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return helix.EventSubCondition{
|
||||
BroadcasterUserID: id,
|
||||
|
@ -260,8 +211,8 @@ func topicCondition(topic string, id string) helix.EventSubCondition {
|
|||
}
|
||||
}
|
||||
|
||||
type WebsocketMessage struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
type EventSubWebsocketMessage struct {
|
||||
Metadata EventSubMetadata `json:"metadata"`
|
||||
Payload jsoniter.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
|
@ -281,7 +232,7 @@ type NotificationMessagePayload struct {
|
|||
Date time.Time `json:"date,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
type EventSubMetadata struct {
|
||||
MessageID string `json:"message_id"`
|
||||
MessageType string `json:"message_type"`
|
||||
MessageTimestamp time.Time `json:"message_timestamp"`
|
||||
|
@ -297,8 +248,6 @@ var subscriptionVersions = map[string]string{
|
|||
helix.EventSubTypeChannelSubscriptionMessage: "1",
|
||||
helix.EventSubTypeChannelCheer: "1",
|
||||
helix.EventSubTypeChannelRaid: "1",
|
||||
helix.EventSubTypeChannelChatMessage: "1",
|
||||
helix.EventSubTypeChannelChatNotification: "1",
|
||||
helix.EventSubTypeChannelPollBegin: "1",
|
||||
helix.EventSubTypeChannelPollProgress: "1",
|
||||
helix.EventSubTypeChannelPollEnd: "1",
|
312
twitch/client.go
Normal file
312
twitch/client.go
Normal file
|
@ -0,0 +1,312 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~ashkeel/containers/sync"
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
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/webserver"
|
||||
)
|
||||
|
||||
var json = jsoniter.ConfigFastest
|
||||
|
||||
type Manager struct {
|
||||
client *Client
|
||||
cancelSubs func()
|
||||
}
|
||||
|
||||
func NewManager(db *database.LocalDBClient, server *webserver.WebServer, logger *zap.Logger) (*Manager, error) {
|
||||
// Get Twitch config
|
||||
var config Config
|
||||
if err := db.GetJSON(ConfigKey, &config); err != nil {
|
||||
if !errors.Is(err, database.ErrEmptyKey) {
|
||||
return nil, fmt.Errorf("failed to get twitch config: %w", err)
|
||||
}
|
||||
config.Enabled = false
|
||||
}
|
||||
|
||||
// Get Twitch bot config
|
||||
botConfig := defaultBotConfig()
|
||||
if err := db.GetJSON(BotConfigKey, &botConfig); err != nil {
|
||||
if !errors.Is(err, database.ErrEmptyKey) {
|
||||
return nil, fmt.Errorf("failed to get bot config: %w", err)
|
||||
}
|
||||
config.EnableBot = false
|
||||
}
|
||||
|
||||
// Create new client
|
||||
client, err := newClient(config, db, server, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create twitch client: %w", err)
|
||||
}
|
||||
|
||||
if config.EnableBot {
|
||||
client.Bot = newBot(client, botConfig)
|
||||
go client.Bot.Connect()
|
||||
}
|
||||
|
||||
manager := &Manager{
|
||||
client: client,
|
||||
}
|
||||
|
||||
// Listen for client config changes
|
||||
cancelConfigSub, err := db.SubscribeKey(ConfigKey, func(value string) {
|
||||
var newConfig Config
|
||||
if err := json.UnmarshalFromString(value, &newConfig); err != nil {
|
||||
logger.Error("Failed to decode Twitch integration config", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
var updatedClient *Client
|
||||
updatedClient, err = newClient(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 {
|
||||
logger.Error("Could not setup twitch config reload subscription", zap.Error(err))
|
||||
}
|
||||
|
||||
// Listen for bot config changes
|
||||
cancelBotSub, err := db.SubscribeKey(BotConfigKey, func(value string) {
|
||||
newBotConfig := defaultBotConfig()
|
||||
if err := json.UnmarshalFromString(value, &newBotConfig); err != nil {
|
||||
logger.Error("Failed to decode bot config", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if manager.client.Bot != nil {
|
||||
err = manager.client.Bot.Close()
|
||||
if err != nil {
|
||||
manager.client.logger.Warn("Failed to disconnect old bot from Twitch IRC", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if manager.client.Config.Get().EnableBot {
|
||||
bot := newBot(manager.client, newBotConfig)
|
||||
go bot.Connect()
|
||||
manager.client.Bot = bot
|
||||
} else {
|
||||
manager.client.Bot = nil
|
||||
}
|
||||
|
||||
manager.client.logger.Info("Reloaded/restarted Twitch bot")
|
||||
})
|
||||
if err != nil {
|
||||
client.logger.Error("Could not setup twitch bot config reload subscription", zap.Error(err))
|
||||
}
|
||||
|
||||
manager.cancelSubs = func() {
|
||||
if cancelConfigSub != nil {
|
||||
cancelConfigSub()
|
||||
}
|
||||
if cancelBotSub != nil {
|
||||
cancelBotSub()
|
||||
}
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
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[Config]
|
||||
Bot *Bot
|
||||
db *database.LocalDBClient
|
||||
API *helix.Client
|
||||
User helix.User
|
||||
logger *zap.Logger
|
||||
eventCache *lru.Cache[string, time.Time]
|
||||
server *webserver.WebServer
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
restart chan bool
|
||||
streamOnline *sync.RWSync[bool]
|
||||
savedSubscriptions map[string]bool
|
||||
}
|
||||
|
||||
func (c *Client) Merge(old *Client) {
|
||||
// Copy bot instance and some params
|
||||
c.streamOnline.Set(old.streamOnline.Get())
|
||||
c.Bot = old.Bot
|
||||
c.ensureRoute()
|
||||
}
|
||||
|
||||
// Hacky function to deal with sync issues when restarting client
|
||||
func (c *Client) ensureRoute() {
|
||||
if c.Config.Get().Enabled {
|
||||
c.server.RegisterRoute(CallbackRoute, c)
|
||||
}
|
||||
}
|
||||
|
||||
func newClient(config Config, db *database.LocalDBClient, server *webserver.WebServer, logger *zap.Logger) (*Client, error) {
|
||||
eventCache, err := lru.New[string, time.Time](128)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create LRU cache for events: %w", err)
|
||||
}
|
||||
|
||||
// Create Twitch client
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
client := &Client{
|
||||
Config: sync.NewRWSync(config),
|
||||
db: db,
|
||||
logger: logger.With(zap.String("service", "twitch")),
|
||||
restart: make(chan bool, 128),
|
||||
streamOnline: sync.NewRWSync(false),
|
||||
eventCache: eventCache,
|
||||
savedSubscriptions: make(map[string]bool),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
server: server,
|
||||
}
|
||||
|
||||
baseurl, err := client.baseURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.Enabled {
|
||||
api, err := getHelixAPI(config, baseurl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create twitch client: %w", err)
|
||||
}
|
||||
|
||||
client.API = api
|
||||
server.RegisterRoute(CallbackRoute, client)
|
||||
|
||||
if userClient, err := client.GetUserClient(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]
|
||||
go client.eventSubLoop(userClient)
|
||||
}
|
||||
} else {
|
||||
client.logger.Warn("Twitch user not identified, this will break most features")
|
||||
}
|
||||
|
||||
go client.runStatusPoll()
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) runStatusPoll() {
|
||||
c.logger.Info("Started polling for stream status")
|
||||
for {
|
||||
// Make sure we're configured and connected properly first
|
||||
if !c.Config.Get().Enabled || c.Bot == nil || c.Bot.Config.Channel == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if streamer is online, if possible
|
||||
func() {
|
||||
status, err := c.API.GetStreams(&helix.StreamsParams{
|
||||
UserLogins: []string{c.Bot.Config.Channel}, // TODO Replace with something non bot dependant
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Error("Error checking stream status", zap.Error(err))
|
||||
return
|
||||
}
|
||||
c.streamOnline.Set(len(status.Data.Streams) > 0)
|
||||
|
||||
err = c.db.PutJSON(StreamInfoKey, status.Data.Streams)
|
||||
if err != nil {
|
||||
c.logger.Warn("Error saving stream info", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for next poll (or cancellation)
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getHelixAPI(config Config, baseurl string) (*helix.Client, error) {
|
||||
redirectURI := getRedirectURI(baseurl)
|
||||
|
||||
// Create Twitch client
|
||||
api, err := helix.NewClient(&helix.Options{
|
||||
ClientID: config.APIClientID,
|
||||
ClientSecret: config.APIClientSecret,
|
||||
RedirectURI: redirectURI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get access token
|
||||
resp, err := api.RequestAppAccessToken([]string{"user:read:email"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Set the access token on the client
|
||||
api.SetAppAccessToken(resp.Data.AccessToken)
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
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) IsLive() bool {
|
||||
return c.streamOnline.Get()
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.server.UnregisterRoute(CallbackRoute)
|
||||
defer c.cancel()
|
||||
|
||||
if c.Bot != nil {
|
||||
if err := c.Bot.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
)
|
||||
|
||||
func (c *Client) GetLoggedUser() (helix.User, error) {
|
||||
if c.User.ID != "" {
|
||||
return c.User, nil
|
||||
}
|
||||
|
||||
client, err := twitch.GetUserClient(c.DB, false)
|
||||
if err != nil {
|
||||
return helix.User{}, fmt.Errorf("failed getting API client for user: %w", err)
|
||||
}
|
||||
|
||||
users, err := client.GetUsers(&helix.UsersParams{})
|
||||
if err != nil {
|
||||
return helix.User{}, fmt.Errorf("failed looking up user: %w", err)
|
||||
}
|
||||
if len(users.Data.Users) < 1 {
|
||||
return helix.User{}, fmt.Errorf("no users found")
|
||||
}
|
||||
c.User = users.Data.Users[0]
|
||||
|
||||
return c.User, nil
|
||||
}
|
||||
|
||||
func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Get code from params
|
||||
code := req.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
// TODO Nice error page
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for access/refresh tokens
|
||||
userTokenResponse, err := c.API.RequestUserAccessToken(code)
|
||||
if err != nil {
|
||||
http.Error(w, "failed auth token request: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.DB.PutJSON(twitch.AuthKey, twitch.AuthResponse{
|
||||
AccessToken: userTokenResponse.Data.AccessToken,
|
||||
RefreshToken: userTokenResponse.Data.RefreshToken,
|
||||
ExpiresIn: userTokenResponse.Data.ExpiresIn,
|
||||
Scope: userTokenResponse.Data.Scopes,
|
||||
Time: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "error saving auth data for user: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, `<html><body><h2>All done, you can close me now!</h2><script>window.close();</script></body></html>`)
|
||||
}
|
||||
|
||||
type RefreshResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope []string `json:"scope"`
|
||||
}
|
|
@ -1,224 +0,0 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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/webserver"
|
||||
)
|
||||
|
||||
var json = jsoniter.ConfigFastest
|
||||
|
||||
type Manager struct {
|
||||
client *Client
|
||||
cancelSubs func()
|
||||
}
|
||||
|
||||
func NewManager(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 {
|
||||
if !errors.Is(err, database.ErrEmptyKey) {
|
||||
return nil, fmt.Errorf("failed to get twitch config: %w", err)
|
||||
}
|
||||
config.Enabled = false
|
||||
}
|
||||
|
||||
// Create new client
|
||||
client, err := newClient(config, db, server, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create twitch client: %w", err)
|
||||
}
|
||||
|
||||
manager := &Manager{
|
||||
client: client,
|
||||
}
|
||||
|
||||
// Listen for client config changes
|
||||
cancelConfigSub, err := db.SubscribeKey(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
|
||||
}
|
||||
|
||||
var updatedClient *Client
|
||||
updatedClient, err = newClient(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 {
|
||||
logger.Error("Could not setup twitch config reload subscription", zap.Error(err))
|
||||
}
|
||||
|
||||
manager.cancelSubs = func() {
|
||||
if cancelConfigSub != nil {
|
||||
cancelConfigSub()
|
||||
}
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
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
|
||||
API *helix.Client
|
||||
User helix.User
|
||||
Logger *zap.Logger
|
||||
|
||||
eventSub *eventsub.Client
|
||||
server *webserver.WebServer
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
restart chan bool
|
||||
streamOnline *sync.RWSync[bool]
|
||||
}
|
||||
|
||||
func (c *Client) Merge(old *Client) {
|
||||
// Copy bot instance and some params
|
||||
c.streamOnline.Set(old.streamOnline.Get())
|
||||
c.ensureRoute()
|
||||
}
|
||||
|
||||
// Hacky function to deal with sync issues when restarting client
|
||||
func (c *Client) ensureRoute() {
|
||||
if c.Config.Get().Enabled {
|
||||
c.server.RegisterRoute(twitch.CallbackRoute, c)
|
||||
}
|
||||
}
|
||||
|
||||
func newClient(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,
|
||||
Logger: logger.With(zap.String("service", "twitch")),
|
||||
restart: make(chan bool, 128),
|
||||
streamOnline: sync.NewRWSync(false),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
server: server,
|
||||
}
|
||||
|
||||
if config.Enabled {
|
||||
var err error
|
||||
client.API, err = twitch.GetHelixAPI(db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create twitch client: %w", err)
|
||||
}
|
||||
|
||||
server.RegisterRoute(twitch.CallbackRoute, client)
|
||||
|
||||
if userClient, err := twitch.GetUserClient(db, true); err == nil {
|
||||
users, err := userClient.GetUsers(&helix.UsersParams{})
|
||||
if err != nil {
|
||||
client.Logger.Error("Failed looking up user", zap.Error(err))
|
||||
} else if len(users.Data.Users) < 1 {
|
||||
client.Logger.Error("No users found, please authenticate in Twitch configuration -> Events")
|
||||
} else {
|
||||
client.User = users.Data.Users[0]
|
||||
client.eventSub, err = eventsub.Setup(ctx, userClient, client.User, db, logger)
|
||||
if err != nil {
|
||||
client.Logger.Error("Failed to setup EventSub", zap.Error(err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
client.Logger.Warn("Twitch user not identified, this will break most features")
|
||||
}
|
||||
|
||||
go client.runStatusPoll()
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) runStatusPoll() {
|
||||
c.Logger.Info("Started polling for stream status")
|
||||
for {
|
||||
// Make sure we're configured and connected properly first
|
||||
if !c.Config.Get().Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
if err != nil {
|
||||
c.Logger.Error("Error checking stream status", zap.Error(err))
|
||||
return
|
||||
}
|
||||
c.streamOnline.Set(len(status.Data.Streams) > 0)
|
||||
|
||||
err = c.DB.PutJSON(twitch.StreamInfoKey, status.Data.Streams)
|
||||
if err != nil {
|
||||
c.Logger.Warn("Error saving stream info", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for next poll (or cancellation)
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
textTemplate "text/template"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch/template"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const ChatCounterPrefix = "twitch/chat/counters/"
|
||||
|
||||
type templateEngineImpl struct {
|
||||
customFunctions textTemplate.FuncMap
|
||||
}
|
||||
|
||||
func (b *templateEngineImpl) MakeTemplate(message string) (*textTemplate.Template, error) {
|
||||
return textTemplate.New("").Funcs(sprig.TxtFuncMap()).Funcs(b.customFunctions).Parse(message)
|
||||
}
|
||||
|
||||
func (c *Client) GetTemplateEngine() template.Engine {
|
||||
return &templateEngineImpl{
|
||||
customFunctions: textTemplate.FuncMap{
|
||||
"user": func(message helix.EventSubChannelChatMessageEvent) string {
|
||||
return message.ChatterUserLogin
|
||||
},
|
||||
"param": func(num int, message helix.EventSubChannelChatMessageEvent) string {
|
||||
parts := strings.Split(message.Message.Text, " ")
|
||||
if num >= len(parts) {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return parts[num]
|
||||
},
|
||||
"randomInt": func(min int, max int) int {
|
||||
return rand.Intn(max-min) + min
|
||||
},
|
||||
"game": func(channel string) string {
|
||||
channel = strings.TrimLeft(channel, "@")
|
||||
info, err := c.API.SearchChannels(&helix.SearchChannelsParams{Channel: channel, First: 1, LiveOnly: false})
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return info.Data.Channels[0].GameName
|
||||
},
|
||||
"count": func(name string) int {
|
||||
counterKey := ChatCounterPrefix + name
|
||||
counter := 0
|
||||
if byt, err := c.DB.GetKey(counterKey); err == nil {
|
||||
counter, _ = strconv.Atoi(byt)
|
||||
}
|
||||
counter++
|
||||
err := c.DB.PutKey(counterKey, strconv.Itoa(counter))
|
||||
if err != nil {
|
||||
c.Logger.Error("Error saving key", zap.Error(err), zap.String("key", counterKey))
|
||||
}
|
||||
return counter
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,10 +1,8 @@
|
|||
package client
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/twitch"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
|
@ -21,7 +19,7 @@ func TestNewClient(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config := twitch.Config{}
|
||||
config := Config{}
|
||||
_, err = newClient(config, client, server, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
189
twitch/commands.go
Normal file
189
twitch/commands.go
Normal file
|
@ -0,0 +1,189 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
irc "github.com/gempir/go-twitch-irc/v4"
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccessLevelType string
|
||||
|
||||
const (
|
||||
ALTEveryone AccessLevelType = "everyone"
|
||||
ALTSubscribers AccessLevelType = "subscriber"
|
||||
ALTVIP AccessLevelType = "vip"
|
||||
ALTModerators AccessLevelType = "moderators"
|
||||
ALTStreamer AccessLevelType = "streamer"
|
||||
)
|
||||
|
||||
var accessLevels = map[AccessLevelType]int{
|
||||
ALTEveryone: 0,
|
||||
ALTSubscribers: 1,
|
||||
ALTVIP: 2,
|
||||
ALTModerators: 3,
|
||||
ALTStreamer: 999,
|
||||
}
|
||||
|
||||
type BotCommandHandler func(bot *Bot, message irc.PrivateMessage)
|
||||
|
||||
type BotCommand struct {
|
||||
Description string
|
||||
Usage string
|
||||
AccessLevel AccessLevelType
|
||||
Handler BotCommandHandler
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func cmdCustom(bot *Bot, cmd string, data BotCustomCommand, message irc.PrivateMessage) {
|
||||
// Check access level
|
||||
accessLevel := getUserAccessLevel(message.User)
|
||||
|
||||
// Ensure that access level is high enough
|
||||
if accessLevels[accessLevel] < accessLevels[data.AccessLevel] {
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tpl, ok := bot.customTemplates.GetKey(cmd)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := tpl.Execute(&buf, message); err != nil {
|
||||
bot.logger.Error("Failed to execute custom command template", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
switch data.ResponseType {
|
||||
case ResponseTypeDefault, ResponseTypeChat:
|
||||
bot.Client.Say(message.Channel, buf.String())
|
||||
case ResponseTypeReply:
|
||||
bot.Client.Reply(message.Channel, message.ID, buf.String())
|
||||
case ResponseTypeWhisper:
|
||||
client, err := bot.api.GetUserClient(false)
|
||||
if err != nil {
|
||||
bot.logger.Error("Failed to retrieve client", zap.Error(err))
|
||||
return
|
||||
}
|
||||
reply, err := client.SendUserWhisper(&helix.SendUserWhisperParams{
|
||||
FromUserID: bot.api.User.ID,
|
||||
ToUserID: message.User.ID,
|
||||
Message: buf.String(),
|
||||
})
|
||||
if reply.Error != "" {
|
||||
bot.logger.Error("Failed to send whisper", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
|
||||
}
|
||||
if err != nil {
|
||||
bot.logger.Error("Failed to send whisper", zap.Error(err))
|
||||
}
|
||||
|
||||
case ResponseTypeAnnounce:
|
||||
client, err := bot.api.GetUserClient(false)
|
||||
if err != nil {
|
||||
bot.logger.Error("Failed to retrieve client", zap.Error(err))
|
||||
return
|
||||
}
|
||||
reply, err := client.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
|
||||
BroadcasterID: bot.api.User.ID,
|
||||
ModeratorID: bot.api.User.ID,
|
||||
Message: buf.String(),
|
||||
})
|
||||
if reply.Error != "" {
|
||||
bot.logger.Error("Failed to send announcement", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
|
||||
}
|
||||
if err != nil {
|
||||
bot.logger.Error("Failed to send announcement", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) MakeTemplate(message string) (*template.Template, error) {
|
||||
return template.New("").Funcs(sprig.TxtFuncMap()).Funcs(b.customFunctions).Parse(message)
|
||||
}
|
||||
|
||||
var TestMessageData = irc.PrivateMessage{
|
||||
User: irc.User{
|
||||
ID: "603448316",
|
||||
Name: "ashkeelvt",
|
||||
DisplayName: "AshKeelVT",
|
||||
Color: "#EC2B87",
|
||||
Badges: map[string]int{
|
||||
"subscriber": 0,
|
||||
"moments": 1,
|
||||
"broadcaster": 1,
|
||||
},
|
||||
},
|
||||
Type: 1,
|
||||
Tags: map[string]string{
|
||||
"emotes": "",
|
||||
"first-msg": "0",
|
||||
"id": "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
|
||||
"turbo": "0",
|
||||
"user-id": "603448316",
|
||||
"badges": "broadcaster/1,subscriber/0,moments/1",
|
||||
"color": "#EC2B87",
|
||||
"user-type": "",
|
||||
"room-id": "603448316",
|
||||
"tmi-sent-ts": "1684345559394",
|
||||
"flags": "",
|
||||
"mod": "0",
|
||||
"returning-chatter": "0",
|
||||
"badge-info": "subscriber/21",
|
||||
"display-name": "AshKeelVT",
|
||||
"subscriber": "1",
|
||||
},
|
||||
Message: "!test",
|
||||
Channel: "ashkeelvt",
|
||||
RoomID: "603448316",
|
||||
ID: "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
|
||||
Time: time.Now(),
|
||||
Emotes: nil,
|
||||
Bits: 0,
|
||||
Action: false,
|
||||
}
|
||||
|
||||
func (b *Bot) setupFunctions() {
|
||||
b.customFunctions = template.FuncMap{
|
||||
"user": func(message irc.PrivateMessage) string {
|
||||
return message.User.DisplayName
|
||||
},
|
||||
"param": func(num int, message irc.PrivateMessage) string {
|
||||
parts := strings.Split(message.Message, " ")
|
||||
if num >= len(parts) {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return parts[num]
|
||||
},
|
||||
"randomInt": func(min int, max int) int {
|
||||
return rand.Intn(max-min) + min
|
||||
},
|
||||
"game": func(channel string) string {
|
||||
channel = strings.TrimLeft(channel, "@")
|
||||
info, err := b.api.API.SearchChannels(&helix.SearchChannelsParams{Channel: channel, First: 1, LiveOnly: false})
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return info.Data.Channels[0].GameName
|
||||
},
|
||||
"count": func(name string) int {
|
||||
counterKey := BotCounterPrefix + name
|
||||
counter := 0
|
||||
if byt, err := b.api.db.GetKey(counterKey); err == nil {
|
||||
counter, _ = strconv.Atoi(byt)
|
||||
}
|
||||
counter++
|
||||
err := b.api.db.PutKey(counterKey, strconv.Itoa(counter))
|
||||
if err != nil {
|
||||
b.logger.Error("Error saving key", zap.Error(err), zap.String("key", counterKey))
|
||||
}
|
||||
return counter
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
package twitch
|
||||
|
||||
const ConfigKey = "twitch/config"
|
||||
|
||||
// Config is the general configuration for the Twitch subsystem
|
||||
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"`
|
||||
}
|
127
twitch/data.go
127
twitch/data.go
|
@ -1,39 +1,104 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
)
|
||||
|
||||
const CallbackRoute = "/twitch/callback"
|
||||
|
||||
const ConfigKey = "twitch/config"
|
||||
|
||||
// Config is the general configuration for the Twitch subsystem
|
||||
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"`
|
||||
}
|
||||
|
||||
const StreamInfoKey = "twitch/stream-info"
|
||||
|
||||
const BotConfigKey = "twitch/bot-config"
|
||||
|
||||
// BotConfig is the general configuration for the Twitch chatbot
|
||||
type BotConfig struct {
|
||||
// Chatbot username (for internal use, ignored by Twitch)
|
||||
Username string `json:"username" desc:"Chatbot username (for internal use, ignored by Twitch)"`
|
||||
|
||||
// OAuth key for IRC authentication
|
||||
Token string `json:"oauth" desc:"OAuth key for IRC authentication"`
|
||||
|
||||
// Twitch channel to join and use
|
||||
Channel string `json:"channel" desc:"Twitch channel to join and use"`
|
||||
|
||||
// How many messages to keep in twitch/chat-history
|
||||
ChatHistory int `json:"chat_history" desc:"How many messages to keep in twitch/chat-history"`
|
||||
|
||||
// Global command cooldown in seconds
|
||||
CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"`
|
||||
}
|
||||
|
||||
const (
|
||||
ChatEventKey = "twitch/ev/chat-message"
|
||||
ChatHistoryKey = "twitch/chat-history"
|
||||
ChatActivityKey = "twitch/chat-activity"
|
||||
)
|
||||
|
||||
type ResponseType string
|
||||
|
||||
const (
|
||||
ResponseTypeDefault ResponseType = ""
|
||||
ResponseTypeChat ResponseType = "chat"
|
||||
ResponseTypeWhisper ResponseType = "whisper"
|
||||
ResponseTypeReply ResponseType = "reply"
|
||||
ResponseTypeAnnounce ResponseType = "announce"
|
||||
)
|
||||
|
||||
// BotCustomCommand is a definition of a custom command of the chatbot
|
||||
type BotCustomCommand struct {
|
||||
// Command description
|
||||
Description string `json:"description" desc:"Command description"`
|
||||
|
||||
// Minimum access level needed to use the command
|
||||
AccessLevel AccessLevelType `json:"access_level" desc:"Minimum access level needed to use the command"`
|
||||
|
||||
// Response template (in Go templating format)
|
||||
Response string `json:"response" desc:"Response template (in Go templating format)"`
|
||||
|
||||
// Is the command enabled?
|
||||
Enabled bool `json:"enabled" desc:"Is the command enabled?"`
|
||||
|
||||
// How to respond to the user
|
||||
ResponseType ResponseType `json:"response_type" desc:"How to respond to the user"`
|
||||
}
|
||||
|
||||
const CustomCommandsKey = "twitch/bot-custom-commands"
|
||||
|
||||
const (
|
||||
// WritePlainMessageRPC is the old send command, will be renamed someday
|
||||
WritePlainMessageRPC = "twitch/@send-chat-message"
|
||||
|
||||
WriteMessageRPC = "twitch/bot/@send-message"
|
||||
)
|
||||
|
||||
// WriteMessageRequest is an RPC to send a chat message with extra options
|
||||
type WriteMessageRequest struct {
|
||||
Message string `json:"message" desc:"Chat message to send"`
|
||||
ReplyTo *string `json:"reply_to" desc:"If specified, send as reply to a message ID"`
|
||||
WhisperTo *string `json:"whisper_to" desc:"If specified, send as whisper to user ID"`
|
||||
Announce bool `json:"announce" desc:"If true, send as announcement"`
|
||||
}
|
||||
|
||||
const BotCounterPrefix = "twitch/bot-counters/"
|
||||
|
||||
const AuthKey = "twitch/auth-keys"
|
||||
|
||||
var TestMessageData = helix.EventSubChannelChatMessageEvent{
|
||||
ChatterUserLogin: "ashkeelvt",
|
||||
ChatterUserID: "603448316",
|
||||
ChatterUserName: "AshKeelVT",
|
||||
Color: "#EC2B87",
|
||||
Badges: []helix.EventSubChatBadge{
|
||||
{
|
||||
SetID: "broadcaster",
|
||||
ID: "1",
|
||||
},
|
||||
{
|
||||
SetID: "subscriber",
|
||||
ID: "21",
|
||||
},
|
||||
},
|
||||
MessageID: "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
|
||||
Message: helix.EventSubChatMessage{
|
||||
Text: "!test param1 param2 param3 param4",
|
||||
Fragments: []helix.EventSubChatMessageFragment{
|
||||
{
|
||||
Text: "!test param1 param2 param3 param4",
|
||||
Type: "text",
|
||||
},
|
||||
},
|
||||
},
|
||||
MessageType: "chat",
|
||||
}
|
||||
const (
|
||||
EventSubEventKey = "twitch/ev/eventsub-event"
|
||||
EventSubHistoryKey = "twitch/eventsub-history"
|
||||
)
|
||||
|
||||
const EventSubHistorySize = 100
|
||||
|
|
96
twitch/doc.go
Normal file
96
twitch/doc.go
Normal file
|
@ -0,0 +1,96 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
|
||||
irc "github.com/gempir/go-twitch-irc/v4"
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
)
|
||||
|
||||
// Documentation stuff, keep updated at all times
|
||||
|
||||
var Keys = interfaces.KeyMap{
|
||||
ConfigKey: interfaces.KeyDef{
|
||||
Description: "General configuration for the Twitch subsystem",
|
||||
Type: reflect.TypeOf(Config{}),
|
||||
},
|
||||
StreamInfoKey: interfaces.KeyDef{
|
||||
Description: "List of active twitch streams (1 element if live, 0 otherwise)",
|
||||
Type: reflect.TypeOf([]helix.Stream{}),
|
||||
},
|
||||
BotConfigKey: interfaces.KeyDef{
|
||||
Description: "General configuration for the Twitch chatbot",
|
||||
Type: reflect.TypeOf(BotConfig{}),
|
||||
},
|
||||
ChatEventKey: interfaces.KeyDef{
|
||||
Description: "On chat message received",
|
||||
Type: reflect.TypeOf(irc.PrivateMessage{}),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagEvent},
|
||||
},
|
||||
ChatHistoryKey: interfaces.KeyDef{
|
||||
Description: "Last chat messages received",
|
||||
Type: reflect.TypeOf([]irc.PrivateMessage{}),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagHistory},
|
||||
},
|
||||
ChatActivityKey: interfaces.KeyDef{
|
||||
Description: "Number of chat messages in the last minute",
|
||||
Type: reflect.TypeOf(0),
|
||||
},
|
||||
CustomCommandsKey: interfaces.KeyDef{
|
||||
Description: "Chatbot custom commands",
|
||||
Type: reflect.TypeOf(map[string]BotCustomCommand{}),
|
||||
},
|
||||
AuthKey: interfaces.KeyDef{
|
||||
Description: "User access token for the twitch subsystem",
|
||||
Type: reflect.TypeOf(AuthResponse{}),
|
||||
},
|
||||
EventSubEventKey: interfaces.KeyDef{
|
||||
Description: "On Eventsub event received",
|
||||
Type: reflect.TypeOf(NotificationMessagePayload{}),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagEvent},
|
||||
},
|
||||
EventSubHistoryKey: interfaces.KeyDef{
|
||||
Description: "Last eventsub notifications received",
|
||||
Type: reflect.TypeOf([]NotificationMessagePayload{}),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagHistory},
|
||||
},
|
||||
BotAlertsKey: interfaces.KeyDef{
|
||||
Description: "Configuration of chat bot alerts",
|
||||
Type: reflect.TypeOf(BotAlertsConfig{}),
|
||||
},
|
||||
BotTimersKey: interfaces.KeyDef{
|
||||
Description: "Configuration of chat bot timers",
|
||||
Type: reflect.TypeOf(BotTimersConfig{}),
|
||||
},
|
||||
WritePlainMessageRPC: interfaces.KeyDef{
|
||||
Description: "Send plain text chat message (this will be deprecated or renamed someday, please use the other one!)",
|
||||
Type: reflect.TypeOf(""),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagRPC},
|
||||
},
|
||||
WriteMessageRPC: interfaces.KeyDef{
|
||||
Description: "Send chat message with extra options (as reply, whisper, etc)",
|
||||
Type: reflect.TypeOf(WriteMessageRequest{}),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagRPC},
|
||||
},
|
||||
}
|
||||
|
||||
var Enums = interfaces.EnumMap{
|
||||
"AccessLevelType": interfaces.Enum{
|
||||
Values: []any{
|
||||
ALTEveryone,
|
||||
ALTSubscribers,
|
||||
ALTVIP,
|
||||
ALTModerators,
|
||||
ALTStreamer,
|
||||
},
|
||||
},
|
||||
"ResponseType": interfaces.Enum{
|
||||
Values: []any{
|
||||
ResponseTypeChat,
|
||||
ResponseTypeReply,
|
||||
ResponseTypeWhisper,
|
||||
ResponseTypeAnnounce,
|
||||
},
|
||||
},
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
package doc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Documentation stuff, keep updated at all times
|
||||
|
||||
var Keys = interfaces.KeyMap{
|
||||
twitch.ConfigKey: interfaces.KeyDef{
|
||||
Description: "General configuration for the Twitch subsystem",
|
||||
Type: reflect.TypeOf(twitch.Config{}),
|
||||
},
|
||||
twitch.StreamInfoKey: interfaces.KeyDef{
|
||||
Description: "List of active twitch streams (1 element if live, 0 otherwise)",
|
||||
Type: reflect.TypeOf([]helix.Stream{}),
|
||||
},
|
||||
twitch.AuthKey: interfaces.KeyDef{
|
||||
Description: "User access token for the twitch subsystem",
|
||||
Type: reflect.TypeOf(twitch.AuthResponse{}),
|
||||
},
|
||||
chat.ConfigKey: interfaces.KeyDef{
|
||||
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),
|
||||
},
|
||||
chat.CustomCommandsKey: interfaces.KeyDef{
|
||||
Description: "Chat custom commands",
|
||||
Type: reflect.TypeOf(map[string]chat.CustomCommand{}),
|
||||
},
|
||||
chat.WriteMessageRPC: interfaces.KeyDef{
|
||||
Description: "Send chat message with extra options (as reply, whisper, etc)",
|
||||
Type: reflect.TypeOf(chat.WriteMessageRequest{}),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagRPC},
|
||||
},
|
||||
eventsub.EventKeyPrefix + "[event-name]": interfaces.KeyDef{
|
||||
Description: "On Eventsub event [event-name] received",
|
||||
Type: reflect.TypeOf(eventsub.NotificationMessagePayload{}),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagEvent},
|
||||
},
|
||||
eventsub.HistoryKeyPrefix + "[event-name]": interfaces.KeyDef{
|
||||
Description: "Last eventsub notifications received for [event-name]",
|
||||
Type: reflect.TypeOf([]eventsub.NotificationMessagePayload{}),
|
||||
Tags: []interfaces.KeyTag{interfaces.TagHistory},
|
||||
},
|
||||
alerts.ConfigKey: interfaces.KeyDef{
|
||||
Description: "Configuration of chat alerts",
|
||||
Type: reflect.TypeOf(alerts.Config{}),
|
||||
},
|
||||
timers.ConfigKey: interfaces.KeyDef{
|
||||
Description: "Configuration of chat timers",
|
||||
Type: reflect.TypeOf(timers.Config{}),
|
||||
},
|
||||
}
|
||||
|
||||
var Enums = interfaces.EnumMap{
|
||||
"AccessLevelType": interfaces.Enum{
|
||||
Values: []any{
|
||||
chat.ALTEveryone,
|
||||
chat.ALTSubscribers,
|
||||
chat.ALTVIP,
|
||||
chat.ALTModerators,
|
||||
chat.ALTStreamer,
|
||||
},
|
||||
},
|
||||
"ResponseType": interfaces.Enum{
|
||||
Values: []any{
|
||||
chat.ResponseTypeChat,
|
||||
chat.ResponseTypeReply,
|
||||
chat.ResponseTypeWhisper,
|
||||
chat.ResponseTypeAnnounce,
|
||||
},
|
||||
},
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package eventsub
|
||||
|
||||
const (
|
||||
EventKeyPrefix = "twitch/ev/eventsub-event/"
|
||||
HistoryKeyPrefix = "twitch/eventsub-history/"
|
||||
)
|
||||
|
||||
const HistorySize = 100
|
|
@ -1,58 +0,0 @@
|
|||
package twitch
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/nicklaw5/helix/v2"
|
||||
|
||||
"git.sr.ht/~ashkeel/strimertul/database"
|
||||
)
|
||||
|
||||
var scopes = []string{
|
||||
"bits:read",
|
||||
"channel:bot",
|
||||
"channel:moderate",
|
||||
"channel:read:hype_train",
|
||||
"channel:read:polls",
|
||||
"channel:read:predictions",
|
||||
"channel:read:redemptions",
|
||||
"channel:read:subscriptions",
|
||||
"chat:edit",
|
||||
"chat:read",
|
||||
"moderator:manage:announcements",
|
||||
"moderator:read:chatters",
|
||||
"moderator:read:followers",
|
||||
"user:bot",
|
||||
"user:manage:whispers",
|
||||
"user:read:chat",
|
||||
"user_read",
|
||||
"whispers:edit",
|
||||
"whispers:read",
|
||||
}
|
||||
|
||||
// CheckScopes checks if the user has authorized all required scopes
|
||||
// Normally this would be the case but between versions strimertul has changed
|
||||
// the required scopes, and it's possible that some users have not re-authorized
|
||||
// the application with the new scopes.
|
||||
func CheckScopes(db database.Database) (bool, error) {
|
||||
var authResp AuthResponse
|
||||
if err := db.GetJSON(AuthKey, &authResp); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Sort scopes for comparison
|
||||
slices.Sort(authResp.Scope)
|
||||
slices.Sort(scopes)
|
||||
|
||||
return slices.Equal(scopes, authResp.Scope), nil
|
||||
}
|
||||
|
||||
func GetAuthorizationURL(api *helix.Client) string {
|
||||
if api == nil {
|
||||
return "twitch-not-configured"
|
||||
}
|
||||
return api.GetAuthorizationURL(&helix.AuthorizationURLParams{
|
||||
ResponseType: "code",
|
||||
Scopes: scopes,
|
||||
})
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package template
|
||||
|
||||
import (
|
||||
textTemplate "text/template"
|
||||
)
|
||||
|
||||
type Engine interface {
|
||||
MakeTemplate(message string) (*textTemplate.Template, error)
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
package timers
|
||||
|
||||
const ConfigKey = "twitch/timers/config"
|
||||
|
||||
type Config struct {
|
||||
Timers map[string]ChatTimer `json:"timers" desc:"List of timers as a dictionary"`
|
||||
}
|
||||
|
||||
type ChatTimer struct {
|
||||
// Whether the timer is enabled
|
||||
Enabled bool `json:"enabled" desc:"Enable the timer"`
|
||||
|
||||
// Timer name (must be unique)
|
||||
Name string `json:"name" desc:"Timer name (must be unique)"`
|
||||
|
||||
// Minimum chat messages in the last 5 minutes for timer to trigger
|
||||
MinimumChatActivity int `json:"minimum_chat_activity" desc:"Minimum chat messages in the last 5 minutes for timer to trigger"`
|
||||
|
||||
// Minimum amount of time (in seconds) that needs to pass before it triggers again
|
||||
MinimumDelay int `json:"minimum_delay" desc:"Minimum amount of time (in seconds) that needs to pass before it triggers again"`
|
||||
|
||||
// Messages to write (randomly chosen)
|
||||
Messages []string `json:"messages" desc:"Messages to write (randomly chosen)"`
|
||||
|
||||
// Announce the message to the chat
|
||||
Announce bool `json:"announce" desc:"If true, send as announcement"`
|
||||
}
|
|
@ -24,7 +24,7 @@ var json = jsoniter.ConfigFastest
|
|||
|
||||
type WebServer struct {
|
||||
Config *sync.RWSync[ServerConfig]
|
||||
db database.Database
|
||||
db *database.LocalDBClient
|
||||
logger *zap.Logger
|
||||
server Server
|
||||
frontend fs.FS
|
||||
|
@ -36,7 +36,7 @@ type WebServer struct {
|
|||
factory ServerFactory
|
||||
}
|
||||
|
||||
func NewServer(db database.Database, logger *zap.Logger, serverFactory ServerFactory) (*WebServer, error) {
|
||||
func NewServer(db *database.LocalDBClient, logger *zap.Logger, serverFactory ServerFactory) (*WebServer, error) {
|
||||
server := &WebServer{
|
||||
logger: logger,
|
||||
db: db,
|
||||
|
|
Loading…
Reference in a new issue