mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-30 02:40:33 +00:00
Compare commits
3 commits
3c3ea7bdb4
...
0d1c60451b
Author | SHA1 | Date | |
---|---|---|---|
|
0d1c60451b | ||
|
bcdecf50c0 | ||
|
f35f3f0458 |
47 changed files with 2511 additions and 2252 deletions
|
@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- The windows can now shrink no more than 480x300 but the UI will now better adapt to small window sizes
|
- 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
|
## 3.3.1 - 2023-11-12
|
||||||
|
|
||||||
|
|
12
app.go
12
app.go
|
@ -12,6 +12,8 @@ import (
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"git.sr.ht/~ashkeel/strimertul/twitch/client"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
|
||||||
kv "github.com/strimertul/kilovolt/v11"
|
kv "github.com/strimertul/kilovolt/v11"
|
||||||
|
@ -41,7 +43,7 @@ type App struct {
|
||||||
cancelLogs database.CancelFunc
|
cancelLogs database.CancelFunc
|
||||||
|
|
||||||
db *database.LocalDBClient
|
db *database.LocalDBClient
|
||||||
twitchManager *twitch.Manager
|
twitchManager *client.Manager
|
||||||
httpServer *webserver.WebServer
|
httpServer *webserver.WebServer
|
||||||
loyaltyManager *loyalty.Manager
|
loyaltyManager *loyalty.Manager
|
||||||
}
|
}
|
||||||
|
@ -146,13 +148,13 @@ func (a *App) initializeComponents() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create twitch client
|
// Create twitch client
|
||||||
a.twitchManager, err = twitch.NewManager(a.db, a.httpServer, logger)
|
a.twitchManager, err = client.NewManager(a.db, a.httpServer, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not initialize twitch client: %w", err)
|
return fmt.Errorf("could not initialize twitch client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize loyalty system
|
// Initialize loyalty system
|
||||||
a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchManager, logger)
|
a.loyaltyManager, err = loyalty.NewManager(a.db, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not initialize loyalty manager: %w", err)
|
return fmt.Errorf("could not initialize loyalty manager: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -225,7 +227,7 @@ func (a *App) GetKilovoltBind() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetTwitchAuthURL() string {
|
func (a *App) GetTwitchAuthURL() string {
|
||||||
return a.twitchManager.Client().GetAuthorizationURL()
|
return twitch.GetAuthorizationURL(a.twitchManager.Client().API)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetTwitchLoggedUser() (helix.User, error) {
|
func (a *App) GetTwitchLoggedUser() (helix.User, error) {
|
||||||
|
@ -296,7 +298,7 @@ func (a *App) GetAppVersion() VersionInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) TestTemplate(message string, data any) error {
|
func (a *App) TestTemplate(message string, data any) error {
|
||||||
tpl, err := a.twitchManager.Client().Bot.MakeTemplate(message)
|
tpl, err := a.twitchManager.Client().GetTemplateEngine().MakeTemplate(message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,19 @@ var (
|
||||||
ErrEmptyKey = errors.New("empty key")
|
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 {
|
type LocalDBClient struct {
|
||||||
client *kv.LocalClient
|
client *kv.LocalClient
|
||||||
hub *kv.Hub
|
hub *kv.Hub
|
||||||
|
|
|
@ -3,7 +3,7 @@ package docs
|
||||||
import (
|
import (
|
||||||
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
|
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
|
||||||
"git.sr.ht/~ashkeel/strimertul/loyalty"
|
"git.sr.ht/~ashkeel/strimertul/loyalty"
|
||||||
"git.sr.ht/~ashkeel/strimertul/twitch"
|
"git.sr.ht/~ashkeel/strimertul/twitch/doc"
|
||||||
"git.sr.ht/~ashkeel/strimertul/utils"
|
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||||
"git.sr.ht/~ashkeel/strimertul/webserver"
|
"git.sr.ht/~ashkeel/strimertul/webserver"
|
||||||
)
|
)
|
||||||
|
@ -25,12 +25,12 @@ func addKeys(keyMap interfaces.KeyMap) {
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Put all enums here
|
// Put all enums here
|
||||||
utils.MergeMap(Enums, twitch.Enums)
|
utils.MergeMap(Enums, doc.Enums)
|
||||||
utils.MergeMap(Enums, enums)
|
utils.MergeMap(Enums, enums)
|
||||||
|
|
||||||
// Put all keys here
|
// Put all keys here
|
||||||
addKeys(strimertulKeys)
|
addKeys(strimertulKeys)
|
||||||
addKeys(twitch.Keys)
|
addKeys(doc.Keys)
|
||||||
addKeys(loyalty.Keys)
|
addKeys(loyalty.Keys)
|
||||||
addKeys(webserver.Keys)
|
addKeys(webserver.Keys)
|
||||||
}
|
}
|
||||||
|
|
|
@ -286,7 +286,10 @@
|
||||||
},
|
},
|
||||||
"quick-links": "Useful links",
|
"quick-links": "Useful links",
|
||||||
"link-user-guide": "User guide",
|
"link-user-guide": "User guide",
|
||||||
"link-api": "API reference"
|
"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."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"welcome-header": "Welcome to {{APPNAME}}",
|
"welcome-header": "Welcome to {{APPNAME}}",
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
import { CircleIcon, InfoCircledIcon, UpdateIcon } from '@radix-ui/react-icons';
|
import {
|
||||||
|
CircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
InfoCircledIcon,
|
||||||
|
UpdateIcon,
|
||||||
|
} from '@radix-ui/react-icons';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
EventSubNotification,
|
EventSubNotification,
|
||||||
|
@ -9,11 +14,16 @@ import { useLiveKey, useModule } from '~/lib/react';
|
||||||
import { useAppDispatch, useAppSelector } from '~/store';
|
import { useAppDispatch, useAppSelector } from '~/store';
|
||||||
import { modules } from '~/store/api/reducer';
|
import { modules } from '~/store/api/reducer';
|
||||||
import * as HoverCard from '@radix-ui/react-hover-card';
|
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 {
|
import {
|
||||||
PageContainer,
|
PageContainer,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
styled,
|
styled,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
|
theme,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from '../theme';
|
} from '../theme';
|
||||||
import BrowserLink from '../components/BrowserLink';
|
import BrowserLink from '../components/BrowserLink';
|
||||||
|
@ -137,7 +147,7 @@ function TwitchEvent({ data }: { data: EventSubNotification }) {
|
||||||
const client = useAppSelector((state) => state.api.client);
|
const client = useAppSelector((state) => state.api.client);
|
||||||
|
|
||||||
const replay = () => {
|
const replay = () => {
|
||||||
void client.putJSON('twitch/ev/eventsub-event', {
|
void client.putJSON(`twitch/ev/eventsub-event/${data.subscription.type}`, {
|
||||||
...data,
|
...data,
|
||||||
subscription: {
|
subscription: {
|
||||||
...data.subscription,
|
...data.subscription,
|
||||||
|
@ -420,10 +430,33 @@ function TwitchStreamStatus({ info }: { info: StreamInfo }) {
|
||||||
function TwitchSection() {
|
function TwitchSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const twitchInfo = useLiveKey<StreamInfo[]>('twitch/stream-info');
|
const twitchInfo = useLiveKey<StreamInfo[]>('twitch/stream-info');
|
||||||
// const twitchActivity = useLiveKey<StreamInfo[]>('twitch/chat-activity');
|
const kv = useAppSelector((state) => state.api.client);
|
||||||
const twitchEvents = useLiveKey<EventSubNotification[]>(
|
const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]);
|
||||||
'twitch/eventsub-history',
|
|
||||||
);
|
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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -440,8 +473,88 @@ 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() {
|
function ProblemList() {
|
||||||
return <></>;
|
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;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard(): React.ReactElement {
|
export default function Dashboard(): React.ReactElement {
|
||||||
|
|
|
@ -412,12 +412,13 @@ function TwitchEventSubSettings() {
|
||||||
|
|
||||||
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
|
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
|
||||||
const data = eventsubTests[event];
|
const data = eventsubTests[event];
|
||||||
await kv.putJSON('twitch/ev/eventsub-event', {
|
await kv.putJSON(`twitch/ev/eventsub-event/${event}`, {
|
||||||
...data,
|
...data,
|
||||||
subscription: {
|
subscription: {
|
||||||
...data.subscription,
|
...data.subscription,
|
||||||
created_at: new Date().toISOString(),
|
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,6 +16,8 @@ export function GetKilovoltBind():Promise<string>;
|
||||||
|
|
||||||
export function GetLastLogs():Promise<Array<main.LogEntry>>;
|
export function GetLastLogs():Promise<Array<main.LogEntry>>;
|
||||||
|
|
||||||
|
export function GetProblems():Promise<Array<main.Problem>>;
|
||||||
|
|
||||||
export function GetTwitchAuthURL():Promise<string>;
|
export function GetTwitchAuthURL():Promise<string>;
|
||||||
|
|
||||||
export function GetTwitchLoggedUser():Promise<helix.User>;
|
export function GetTwitchLoggedUser():Promise<helix.User>;
|
||||||
|
|
|
@ -26,6 +26,10 @@ export function GetLastLogs() {
|
||||||
return window['go']['main']['App']['GetLastLogs']();
|
return window['go']['main']['App']['GetLastLogs']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetProblems() {
|
||||||
|
return window['go']['main']['App']['GetProblems']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetTwitchAuthURL() {
|
export function GetTwitchAuthURL() {
|
||||||
return window['go']['main']['App']['GetTwitchAuthURL']();
|
return window['go']['main']['App']['GetTwitchAuthURL']();
|
||||||
}
|
}
|
||||||
|
|
|
@ -92,6 +92,20 @@ export namespace main {
|
||||||
this.data = source["data"];
|
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 {
|
export class VersionInfo {
|
||||||
release: string;
|
release: string;
|
||||||
// Go type: debug
|
// Go type: debug
|
||||||
|
|
3
go.mod
3
go.mod
|
@ -10,11 +10,10 @@ require (
|
||||||
github.com/Masterminds/sprig/v3 v3.2.3
|
github.com/Masterminds/sprig/v3 v3.2.3
|
||||||
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29
|
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29
|
||||||
github.com/cockroachdb/pebble v1.1.0
|
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/gorilla/websocket v1.5.1
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/json-iterator/go v1.1.12
|
github.com/json-iterator/go v1.1.12
|
||||||
github.com/nicklaw5/helix/v2 v2.26.0
|
github.com/nicklaw5/helix/v2 v2.28.0
|
||||||
github.com/strimertul/kilovolt/v11 v11.0.1
|
github.com/strimertul/kilovolt/v11 v11.0.1
|
||||||
github.com/urfave/cli/v2 v2.27.1
|
github.com/urfave/cli/v2 v2.27.1
|
||||||
github.com/wailsapp/wails/v2 v2.8.0
|
github.com/wailsapp/wails/v2 v2.8.0
|
||||||
|
|
6
go.sum
6
go.sum
|
@ -92,8 +92,6 @@ 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.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/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/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 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0=
|
||||||
github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ=
|
github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
@ -253,8 +251,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
github.com/nicklaw5/helix/v2 v2.26.0 h1:Qkc/R0eCDdWtUmnczk2g03+mObPUfc49Kz2Bt4B5d0g=
|
github.com/nicklaw5/helix/v2 v2.28.0 h1:BCpIh9gf/7dsTNyxzgY18VHpt9W6/t0zUioyuDhH6tA=
|
||||||
github.com/nicklaw5/helix/v2 v2.26.0/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
|
github.com/nicklaw5/helix/v2 v2.28.0/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
|
||||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||||
|
|
|
@ -7,13 +7,12 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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"
|
"git.sr.ht/~ashkeel/containers/sync"
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"git.sr.ht/~ashkeel/strimertul/database"
|
||||||
|
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var json = jsoniter.ConfigFastest
|
var json = jsoniter.ConfigFastest
|
||||||
|
@ -31,19 +30,17 @@ type Manager struct {
|
||||||
Rewards *sync.Slice[Reward]
|
Rewards *sync.Slice[Reward]
|
||||||
Goals *sync.Slice[Goal]
|
Goals *sync.Slice[Goal]
|
||||||
Queue *sync.Slice[Redeem]
|
Queue *sync.Slice[Redeem]
|
||||||
db *database.LocalDBClient
|
db database.Database
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
cooldowns map[string]time.Time
|
cooldowns map[string]time.Time
|
||||||
banlist map[string]bool
|
banlist map[string]bool
|
||||||
activeUsers *sync.Map[string, bool]
|
|
||||||
twitchManager *twitch.Manager
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancelFn context.CancelFunc
|
cancelFn context.CancelFunc
|
||||||
cancelSub database.CancelFunc
|
cancelSub database.CancelFunc
|
||||||
restartTwitchHandler chan struct{}
|
restartTwitchHandler chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logger *zap.Logger) (*Manager, error) {
|
func NewManager(db database.Database, logger *zap.Logger) (*Manager, error) {
|
||||||
ctx, cancelFn := context.WithCancel(context.Background())
|
ctx, cancelFn := context.WithCancel(context.Background())
|
||||||
loyalty := &Manager{
|
loyalty := &Manager{
|
||||||
Config: sync.NewRWSync(Config{Enabled: false}),
|
Config: sync.NewRWSync(Config{Enabled: false}),
|
||||||
|
@ -56,8 +53,6 @@ func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logge
|
||||||
points: sync.NewMap[string, PointsEntry](),
|
points: sync.NewMap[string, PointsEntry](),
|
||||||
cooldowns: make(map[string]time.Time),
|
cooldowns: make(map[string]time.Time),
|
||||||
banlist: make(map[string]bool),
|
banlist: make(map[string]bool),
|
||||||
activeUsers: sync.NewMap[string, bool](),
|
|
||||||
twitchManager: twitchManager,
|
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancelFn: cancelFn,
|
cancelFn: cancelFn,
|
||||||
restartTwitchHandler: make(chan struct{}),
|
restartTwitchHandler: make(chan struct{}),
|
||||||
|
@ -127,9 +122,6 @@ func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logge
|
||||||
|
|
||||||
loyalty.SetBanList(config.BanList)
|
loyalty.SetBanList(config.BanList)
|
||||||
|
|
||||||
// Setup twitch integration
|
|
||||||
loyalty.SetupTwitch()
|
|
||||||
|
|
||||||
return loyalty, nil
|
return loyalty, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,9 +134,6 @@ func (m *Manager) Close() error {
|
||||||
// Send cancellation
|
// Send cancellation
|
||||||
m.cancelFn()
|
m.cancelFn()
|
||||||
|
|
||||||
// Teardown twitch integration
|
|
||||||
m.StopTwitch()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,8 +146,6 @@ func (m *Manager) update(key, value string) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
m.SetBanList(m.Config.Get().BanList)
|
m.SetBanList(m.Config.Get().BanList)
|
||||||
m.restartTwitchHandler <- struct{}{}
|
m.restartTwitchHandler <- struct{}{}
|
||||||
m.StopTwitch()
|
|
||||||
m.SetupTwitch()
|
|
||||||
}
|
}
|
||||||
case GoalsKey:
|
case GoalsKey:
|
||||||
err = utils.LoadJSONToWrapped[[]Goal](value, m.Goals)
|
err = utils.LoadJSONToWrapped[[]Goal](value, m.Goals)
|
||||||
|
@ -368,3 +355,15 @@ func (m *Manager) Equals(c utils.Comparable) bool {
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -1,360 +0,0 @@
|
||||||
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
Normal file
41
problem.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
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
|
||||||
|
}
|
68
twitch/alerts/config.go
Normal file
68
twitch/alerts/config.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
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"`
|
||||||
|
}
|
210
twitch/alerts/events.go
Normal file
210
twitch/alerts/events.go
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
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
|
||||||
|
}
|
135
twitch/alerts/mixed.go
Normal file
135
twitch/alerts/mixed.go
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
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)
|
||||||
|
}
|
102
twitch/alerts/module.go
Normal file
102
twitch/alerts/module.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
70
twitch/alerts/templates.go
Normal file
70
twitch/alerts/templates.go
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
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
Normal file
114
twitch/api.go
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -1,523 +0,0 @@
|
||||||
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
393
twitch/bot.go
|
@ -1,393 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
104
twitch/chat/commands.go
Normal file
104
twitch/chat/commands.go
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
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)
|
||||||
|
}
|
11
twitch/chat/config.go
Normal file
11
twitch/chat/config.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
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"`
|
||||||
|
}
|
47
twitch/chat/data.go
Normal file
47
twitch/chat/data.go
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
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"`
|
||||||
|
}
|
394
twitch/chat/loyalty.go
Normal file
394
twitch/chat/loyalty.go
Normal file
|
@ -0,0 +1,394 @@
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
270
twitch/chat/module.go
Normal file
270
twitch/chat/module.go
Normal file
|
@ -0,0 +1,270 @@
|
||||||
|
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)
|
||||||
|
}
|
21
twitch/chat/write.go
Normal file
21
twitch/chat/write.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,162 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
312
twitch/client.go
312
twitch/client.go
|
@ -1,312 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
71
twitch/client/auth.go
Normal file
71
twitch/client/auth.go
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
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"`
|
||||||
|
}
|
224
twitch/client/client.go
Normal file
224
twitch/client/client.go
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
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,8 +1,10 @@
|
||||||
package twitch
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.sr.ht/~ashkeel/strimertul/twitch"
|
||||||
|
|
||||||
"go.uber.org/zap/zaptest"
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
"git.sr.ht/~ashkeel/strimertul/database"
|
"git.sr.ht/~ashkeel/strimertul/database"
|
||||||
|
@ -19,7 +21,7 @@ func TestNewClient(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config := Config{}
|
config := twitch.Config{}
|
||||||
_, err = newClient(config, client, server, logger)
|
_, err = newClient(config, client, server, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
66
twitch/client/template.go
Normal file
66
twitch/client/template.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
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,189 +0,0 @@
|
||||||
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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
21
twitch/config.go
Normal file
21
twitch/config.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
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,104 +1,39 @@
|
||||||
package twitch
|
package twitch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/nicklaw5/helix/v2"
|
||||||
|
)
|
||||||
|
|
||||||
const CallbackRoute = "/twitch/callback"
|
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 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"
|
const AuthKey = "twitch/auth-keys"
|
||||||
|
|
||||||
const (
|
var TestMessageData = helix.EventSubChannelChatMessageEvent{
|
||||||
EventSubEventKey = "twitch/ev/eventsub-event"
|
ChatterUserLogin: "ashkeelvt",
|
||||||
EventSubHistoryKey = "twitch/eventsub-history"
|
ChatterUserID: "603448316",
|
||||||
)
|
ChatterUserName: "AshKeelVT",
|
||||||
|
Color: "#EC2B87",
|
||||||
const EventSubHistorySize = 100
|
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",
|
||||||
|
}
|
||||||
|
|
|
@ -1,96 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
96
twitch/doc/doc.go
Normal file
96
twitch/doc/doc.go
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
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,25 +1,62 @@
|
||||||
package twitch
|
package eventsub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sr.ht/~ashkeel/strimertul/utils"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/nicklaw5/helix/v2"
|
"github.com/nicklaw5/helix/v2"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"git.sr.ht/~ashkeel/strimertul/database"
|
||||||
|
"git.sr.ht/~ashkeel/strimertul/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var json = jsoniter.ConfigFastest
|
||||||
|
|
||||||
const websocketEndpoint = "wss://eventsub.wss.twitch.tv/ws"
|
const websocketEndpoint = "wss://eventsub.wss.twitch.tv/ws"
|
||||||
|
|
||||||
func (c *Client) eventSubLoop(userClient *helix.Client) {
|
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() {
|
||||||
endpoint := websocketEndpoint
|
endpoint := websocketEndpoint
|
||||||
var err error
|
var err error
|
||||||
var connection *websocket.Conn
|
var connection *websocket.Conn
|
||||||
for endpoint != "" {
|
for endpoint != "" {
|
||||||
endpoint, connection, err = c.connectWebsocket(endpoint, connection, userClient)
|
endpoint, connection, err = c.connectWebsocket(endpoint, connection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("EventSub websocket read error", zap.Error(err))
|
c.logger.Error("EventSub websocket read error", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
@ -46,7 +83,7 @@ func readLoop(connection *websocket.Conn, recv chan<- []byte, wsErr chan<- error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn, userClient *helix.Client) (string, *websocket.Conn, error) {
|
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn) (string, *websocket.Conn, error) {
|
||||||
connection, _, err := websocket.DefaultDialer.Dial(url, nil)
|
connection, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Could not establish a connection to the EventSub websocket", zap.Error(err))
|
c.logger.Error("Could not establish a connection to the EventSub websocket", zap.Error(err))
|
||||||
|
@ -69,21 +106,21 @@ func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn, use
|
||||||
case messageData = <-received:
|
case messageData = <-received:
|
||||||
}
|
}
|
||||||
|
|
||||||
var wsMessage EventSubWebsocketMessage
|
var wsMessage WebsocketMessage
|
||||||
err = json.Unmarshal(messageData, &wsMessage)
|
err = json.Unmarshal(messageData, &wsMessage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Error decoding EventSub message", zap.Error(err))
|
c.logger.Error("Error decoding EventSub message", zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnectURL, done, err := c.processMessage(wsMessage, oldConnection, userClient)
|
reconnectURL, done, err := c.processMessage(wsMessage, oldConnection)
|
||||||
if done {
|
if done {
|
||||||
return reconnectURL, connection, err
|
return reconnectURL, connection, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnection *websocket.Conn, userClient *helix.Client) (string, bool, error) {
|
func (c *Client) processMessage(wsMessage WebsocketMessage, oldConnection *websocket.Conn) (string, bool, error) {
|
||||||
switch wsMessage.Metadata.MessageType {
|
switch wsMessage.Metadata.MessageType {
|
||||||
case "session_keepalive":
|
case "session_keepalive":
|
||||||
// Nothing to do
|
// Nothing to do
|
||||||
|
@ -102,7 +139,7 @@ func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnectio
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add subscription to websocket session
|
// Add subscription to websocket session
|
||||||
err = c.addSubscriptionsForSession(userClient, welcomeData.Session.ID)
|
err = c.addSubscriptionsForSession(welcomeData.Session.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Could not add subscriptions", zap.Error(err))
|
c.logger.Error("Could not add subscriptions", zap.Error(err))
|
||||||
break
|
break
|
||||||
|
@ -125,7 +162,7 @@ func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnectio
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) processEvent(message EventSubWebsocketMessage) {
|
func (c *Client) processEvent(message WebsocketMessage) {
|
||||||
// Check if we processed this already
|
// Check if we processed this already
|
||||||
if message.Metadata.MessageID != "" {
|
if message.Metadata.MessageID != "" {
|
||||||
if c.eventCache.Contains(message.Metadata.MessageID) {
|
if c.eventCache.Contains(message.Metadata.MessageID) {
|
||||||
|
@ -143,27 +180,30 @@ func (c *Client) processEvent(message EventSubWebsocketMessage) {
|
||||||
}
|
}
|
||||||
notificationData.Date = time.Now()
|
notificationData.Date = time.Now()
|
||||||
|
|
||||||
err = c.db.PutJSON(EventSubEventKey, notificationData)
|
eventKey := fmt.Sprintf("%s%s", EventKeyPrefix, notificationData.Subscription.Type)
|
||||||
|
historyKey := fmt.Sprintf("%s%s", HistoryKeyPrefix, notificationData.Subscription.Type)
|
||||||
|
err = c.db.PutJSON(eventKey, notificationData)
|
||||||
|
c.logger.Info("Stored event", zap.String("key", eventKey), zap.String("notification-type", notificationData.Subscription.Type))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Error storing event to database", zap.String("key", EventSubEventKey), zap.Error(err))
|
c.logger.Error("Error storing event to database", zap.String("key", eventKey), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
var archive []NotificationMessagePayload
|
var archive []NotificationMessagePayload
|
||||||
err = c.db.GetJSON(EventSubHistoryKey, &archive)
|
err = c.db.GetJSON(historyKey, &archive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
archive = []NotificationMessagePayload{}
|
archive = []NotificationMessagePayload{}
|
||||||
}
|
}
|
||||||
archive = append(archive, notificationData)
|
archive = append(archive, notificationData)
|
||||||
if len(archive) > EventSubHistorySize {
|
if len(archive) > HistorySize {
|
||||||
archive = archive[len(archive)-EventSubHistorySize:]
|
archive = archive[len(archive)-HistorySize:]
|
||||||
}
|
}
|
||||||
err = c.db.PutJSON(EventSubHistoryKey, archive)
|
err = c.db.PutJSON(historyKey, archive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Error storing event to database", zap.String("key", EventSubHistoryKey), zap.Error(err))
|
c.logger.Error("Error storing event to database", zap.String("key", historyKey), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) addSubscriptionsForSession(userClient *helix.Client, session string) error {
|
func (c *Client) addSubscriptionsForSession(session string) error {
|
||||||
if c.savedSubscriptions[session] {
|
if c.savedSubscriptions[session] {
|
||||||
// Already subscribed
|
// Already subscribed
|
||||||
return nil
|
return nil
|
||||||
|
@ -174,12 +214,12 @@ func (c *Client) addSubscriptionsForSession(userClient *helix.Client, session st
|
||||||
SessionID: session,
|
SessionID: session,
|
||||||
}
|
}
|
||||||
for topic, version := range subscriptionVersions {
|
for topic, version := range subscriptionVersions {
|
||||||
sub, err := userClient.CreateEventSubSubscription(&helix.EventSubSubscription{
|
sub, err := c.twitchAPI.CreateEventSubSubscription(&helix.EventSubSubscription{
|
||||||
Type: topic,
|
Type: topic,
|
||||||
Version: version,
|
Version: version,
|
||||||
Status: "enabled",
|
Status: "enabled",
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
Condition: topicCondition(topic, c.User.ID),
|
Condition: topicCondition(topic, c.user.ID),
|
||||||
})
|
})
|
||||||
if sub.Error != "" || sub.ErrorMessage != "" {
|
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))
|
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))
|
||||||
|
@ -195,15 +235,24 @@ func (c *Client) addSubscriptionsForSession(userClient *helix.Client, session st
|
||||||
|
|
||||||
func topicCondition(topic string, id string) helix.EventSubCondition {
|
func topicCondition(topic string, id string) helix.EventSubCondition {
|
||||||
switch topic {
|
switch topic {
|
||||||
case "channel.raid":
|
case helix.EventSubTypeChannelRaid:
|
||||||
return helix.EventSubCondition{
|
return helix.EventSubCondition{
|
||||||
ToBroadcasterUserID: id,
|
ToBroadcasterUserID: id,
|
||||||
}
|
}
|
||||||
case "channel.follow":
|
case helix.EventSubTypeChannelFollow:
|
||||||
return helix.EventSubCondition{
|
return helix.EventSubCondition{
|
||||||
BroadcasterUserID: id,
|
BroadcasterUserID: id,
|
||||||
ModeratorUserID: id,
|
ModeratorUserID: id,
|
||||||
}
|
}
|
||||||
|
case
|
||||||
|
helix.EventSubTypeChannelChatMessage,
|
||||||
|
helix.EventSubTypeChannelChatNotification:
|
||||||
|
{
|
||||||
|
return helix.EventSubCondition{
|
||||||
|
BroadcasterUserID: id,
|
||||||
|
UserID: id,
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return helix.EventSubCondition{
|
return helix.EventSubCondition{
|
||||||
BroadcasterUserID: id,
|
BroadcasterUserID: id,
|
||||||
|
@ -211,8 +260,8 @@ func topicCondition(topic string, id string) helix.EventSubCondition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventSubWebsocketMessage struct {
|
type WebsocketMessage struct {
|
||||||
Metadata EventSubMetadata `json:"metadata"`
|
Metadata Metadata `json:"metadata"`
|
||||||
Payload jsoniter.RawMessage `json:"payload"`
|
Payload jsoniter.RawMessage `json:"payload"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -232,7 +281,7 @@ type NotificationMessagePayload struct {
|
||||||
Date time.Time `json:"date,omitempty"`
|
Date time.Time `json:"date,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventSubMetadata struct {
|
type Metadata struct {
|
||||||
MessageID string `json:"message_id"`
|
MessageID string `json:"message_id"`
|
||||||
MessageType string `json:"message_type"`
|
MessageType string `json:"message_type"`
|
||||||
MessageTimestamp time.Time `json:"message_timestamp"`
|
MessageTimestamp time.Time `json:"message_timestamp"`
|
||||||
|
@ -248,6 +297,8 @@ var subscriptionVersions = map[string]string{
|
||||||
helix.EventSubTypeChannelSubscriptionMessage: "1",
|
helix.EventSubTypeChannelSubscriptionMessage: "1",
|
||||||
helix.EventSubTypeChannelCheer: "1",
|
helix.EventSubTypeChannelCheer: "1",
|
||||||
helix.EventSubTypeChannelRaid: "1",
|
helix.EventSubTypeChannelRaid: "1",
|
||||||
|
helix.EventSubTypeChannelChatMessage: "1",
|
||||||
|
helix.EventSubTypeChannelChatNotification: "1",
|
||||||
helix.EventSubTypeChannelPollBegin: "1",
|
helix.EventSubTypeChannelPollBegin: "1",
|
||||||
helix.EventSubTypeChannelPollProgress: "1",
|
helix.EventSubTypeChannelPollProgress: "1",
|
||||||
helix.EventSubTypeChannelPollEnd: "1",
|
helix.EventSubTypeChannelPollEnd: "1",
|
8
twitch/eventsub/data.go
Normal file
8
twitch/eventsub/data.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package eventsub
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventKeyPrefix = "twitch/ev/eventsub-event/"
|
||||||
|
HistoryKeyPrefix = "twitch/eventsub-history/"
|
||||||
|
)
|
||||||
|
|
||||||
|
const HistorySize = 100
|
58
twitch/scopes.go
Normal file
58
twitch/scopes.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
9
twitch/template/template.go
Normal file
9
twitch/template/template.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
textTemplate "text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Engine interface {
|
||||||
|
MakeTemplate(message string) (*textTemplate.Template, error)
|
||||||
|
}
|
27
twitch/timers/config.go
Normal file
27
twitch/timers/config.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
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"`
|
||||||
|
}
|
|
@ -1,56 +1,38 @@
|
||||||
package twitch
|
package timers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sr.ht/~ashkeel/containers/sync"
|
"git.sr.ht/~ashkeel/containers/sync"
|
||||||
irc "github.com/gempir/go-twitch-irc/v4"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"git.sr.ht/~ashkeel/strimertul/database"
|
"git.sr.ht/~ashkeel/strimertul/database"
|
||||||
|
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
|
||||||
)
|
)
|
||||||
|
|
||||||
const BotTimersKey = "twitch/bot-modules/timers/config"
|
var json = jsoniter.ConfigFastest
|
||||||
|
|
||||||
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
|
const AverageMessageWindow = 5
|
||||||
|
|
||||||
type BotTimerModule struct {
|
type Module struct {
|
||||||
Config BotTimersConfig
|
Config Config
|
||||||
|
|
||||||
bot *Bot
|
|
||||||
lastTrigger *sync.Map[string, time.Time]
|
lastTrigger *sync.Map[string, time.Time]
|
||||||
messages *sync.Slice[int]
|
messages *sync.Slice[int]
|
||||||
|
|
||||||
|
logger *zap.Logger
|
||||||
|
db database.Database
|
||||||
cancelTimerSub database.CancelFunc
|
cancelTimerSub database.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetupTimers(bot *Bot) *BotTimerModule {
|
func Setup(db database.Database, logger *zap.Logger) *Module {
|
||||||
mod := &BotTimerModule{
|
mod := &Module{
|
||||||
bot: bot,
|
|
||||||
lastTrigger: sync.NewMap[string, time.Time](),
|
lastTrigger: sync.NewMap[string, time.Time](),
|
||||||
messages: sync.NewSlice[int](),
|
messages: sync.NewSlice[int](),
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill messages with zero values
|
// Fill messages with zero values
|
||||||
|
@ -60,32 +42,32 @@ func SetupTimers(bot *Bot) *BotTimerModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load config from database
|
// Load config from database
|
||||||
err := bot.api.db.GetJSON(BotTimersKey, &mod.Config)
|
err := db.GetJSON(ConfigKey, &mod.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bot.logger.Debug("Config load error", zap.Error(err))
|
logger.Debug("Config load error", zap.Error(err))
|
||||||
mod.Config = BotTimersConfig{
|
mod.Config = Config{
|
||||||
Timers: make(map[string]BotTimer),
|
Timers: make(map[string]ChatTimer),
|
||||||
}
|
}
|
||||||
// Save empty config
|
// Save empty config
|
||||||
err = bot.api.db.PutJSON(BotTimersKey, mod.Config)
|
err = db.PutJSON(ConfigKey, mod.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bot.logger.Warn("Could not save default config for bot timers", zap.Error(err))
|
logger.Warn("Could not save default config for bot timers", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod.cancelTimerSub, err = bot.api.db.SubscribeKey(BotTimersKey, func(value string) {
|
mod.cancelTimerSub, err = db.SubscribeKey(ConfigKey, func(value string) {
|
||||||
err := json.UnmarshalFromString(value, &mod.Config)
|
err := json.UnmarshalFromString(value, &mod.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bot.logger.Debug("Error reloading timer config", zap.Error(err))
|
logger.Debug("Error reloading timer config", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
bot.logger.Info("Reloaded timer config")
|
logger.Info("Reloaded timer config")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bot.logger.Error("Could not set-up timer reload subscription", zap.Error(err))
|
logger.Error("Could not set-up timer reload subscription", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.logger.Debug("Loaded timers", zap.Int("timers", len(mod.Config.Timers)))
|
logger.Debug("Loaded timers", zap.Int("timers", len(mod.Config.Timers)))
|
||||||
|
|
||||||
// Start goroutine for clearing message counters and running timers
|
// Start goroutine for clearing message counters and running timers
|
||||||
go mod.runTimers()
|
go mod.runTimers()
|
||||||
|
@ -93,7 +75,7 @@ func SetupTimers(bot *Bot) *BotTimerModule {
|
||||||
return mod
|
return mod
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *BotTimerModule) runTimers() {
|
func (m *Module) runTimers() {
|
||||||
for {
|
for {
|
||||||
// Wait until next tick (remainder until next minute, as close to 0 seconds as possible)
|
// Wait until next tick (remainder until next minute, as close to 0 seconds as possible)
|
||||||
currentTime := time.Now()
|
currentTime := time.Now()
|
||||||
|
@ -101,9 +83,9 @@ func (m *BotTimerModule) runTimers() {
|
||||||
timeUntilNextTick := nextTick.Sub(currentTime)
|
timeUntilNextTick := nextTick.Sub(currentTime)
|
||||||
time.Sleep(timeUntilNextTick)
|
time.Sleep(timeUntilNextTick)
|
||||||
|
|
||||||
err := m.bot.api.db.PutJSON(ChatActivityKey, m.messages.Get())
|
err := m.db.PutJSON(chat.ActivityKey, m.messages.Get())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.bot.logger.Warn("Error saving chat activity", zap.Error(err))
|
m.logger.Warn("Error saving chat activity", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate activity
|
// Calculate activity
|
||||||
|
@ -122,7 +104,7 @@ func (m *BotTimerModule) runTimers() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *BotTimerModule) ProcessTimer(name string, timer BotTimer, activity int) {
|
func (m *Module) ProcessTimer(name string, timer ChatTimer, activity int) {
|
||||||
// Must be enabled
|
// Must be enabled
|
||||||
if !timer.Enabled {
|
if !timer.Enabled {
|
||||||
return
|
return
|
||||||
|
@ -155,19 +137,22 @@ func (m *BotTimerModule) ProcessTimer(name string, timer BotTimer, activity int)
|
||||||
message := timer.Messages[rand.Intn(len(timer.Messages))]
|
message := timer.Messages[rand.Intn(len(timer.Messages))]
|
||||||
|
|
||||||
// Write message to chat
|
// Write message to chat
|
||||||
m.bot.WriteMessage(message)
|
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
|
||||||
|
Message: message,
|
||||||
|
Announce: timer.Announce,
|
||||||
|
})
|
||||||
|
|
||||||
// Update last trigger
|
// Update last trigger
|
||||||
m.lastTrigger.SetKey(name, now)
|
m.lastTrigger.SetKey(name, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *BotTimerModule) Close() {
|
func (m *Module) Close() {
|
||||||
if m.cancelTimerSub != nil {
|
if m.cancelTimerSub != nil {
|
||||||
m.cancelTimerSub()
|
m.cancelTimerSub()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *BotTimerModule) currentChatActivity() int {
|
func (m *Module) currentChatActivity() int {
|
||||||
total := 0
|
total := 0
|
||||||
for _, v := range m.messages.Get() {
|
for _, v := range m.messages.Get() {
|
||||||
total += v
|
total += v
|
||||||
|
@ -175,7 +160,7 @@ func (m *BotTimerModule) currentChatActivity() int {
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *BotTimerModule) OnMessage(message irc.PrivateMessage) {
|
func (m *Module) OnMessage() {
|
||||||
index := message.Time.Minute() % AverageMessageWindow
|
index := time.Now().Minute() % AverageMessageWindow
|
||||||
m.messages.SetIndex(index, 1)
|
m.messages.SetIndex(index, 1)
|
||||||
}
|
}
|
|
@ -24,7 +24,7 @@ var json = jsoniter.ConfigFastest
|
||||||
|
|
||||||
type WebServer struct {
|
type WebServer struct {
|
||||||
Config *sync.RWSync[ServerConfig]
|
Config *sync.RWSync[ServerConfig]
|
||||||
db *database.LocalDBClient
|
db database.Database
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
server Server
|
server Server
|
||||||
frontend fs.FS
|
frontend fs.FS
|
||||||
|
@ -36,7 +36,7 @@ type WebServer struct {
|
||||||
factory ServerFactory
|
factory ServerFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(db *database.LocalDBClient, logger *zap.Logger, serverFactory ServerFactory) (*WebServer, error) {
|
func NewServer(db database.Database, logger *zap.Logger, serverFactory ServerFactory) (*WebServer, error) {
|
||||||
server := &WebServer{
|
server := &WebServer{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
db: db,
|
db: db,
|
||||||
|
|
Loading…
Reference in a new issue