feat: add eventsub websocket, remove stulbe

This commit is contained in:
Ash Keel 2022-11-23 22:22:49 +01:00
parent 28c275a553
commit 44333fc392
No known key found for this signature in database
GPG Key ID: BAD8D93E7314ED3E
22 changed files with 677 additions and 707 deletions

21
app.go
View File

@ -4,9 +4,12 @@ import (
"context"
"strconv"
"github.com/nicklaw5/helix/v2"
"github.com/strimertul/strimertul/modules"
"github.com/strimertul/strimertul/modules/database"
"github.com/strimertul/strimertul/modules/http"
"github.com/strimertul/strimertul/modules/twitch"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
@ -83,8 +86,6 @@ func (a *App) startup(ctx context.Context) {
// Run HTTP server
go failOnError(httpServer.Listen(), "HTTP server stopped")
// Wait until server is up
}
func (a *App) stop(context.Context) {
@ -104,5 +105,19 @@ func (a *App) IsServerReady() bool {
}
func (a *App) GetKilovoltBind() string {
return a.manager.Modules[modules.ModuleHTTP].Status().Data.(http.StatusData).Bind
if a.manager == nil {
return ""
}
if httpModule, ok := a.manager.Modules[modules.ModuleHTTP]; ok {
return httpModule.Status().Data.(http.StatusData).Bind
}
return ""
}
func (a *App) GetTwitchAuthURL() string {
return a.manager.Modules[modules.ModuleTwitch].(*twitch.Client).GetAuthorizationURL()
}
func (a *App) GetTwitchLoggedUser() (helix.User, error) {
return a.manager.Modules[modules.ModuleTwitch].(*twitch.Client).GetLoggedUser()
}

View File

View File

@ -1 +1 @@
24591b6315c04d57021b01b6fdb4e73f
1de5a15d31eda16cf49dfabfe8142773

View File

@ -43,36 +43,6 @@
"static-help": "Will be served at the following URL: {{url}}",
"saving": "Saving webserver settings..."
},
"stulbe": {
"title": "Back-end integration",
"test-success": "Connection test was successful!",
"test-button": "Test connection",
"endpoint": "Back-end endpoint",
"auth-key": "Authorization key",
"username": "User name",
"subtitle": "Optional back-end integration (using <1>stulbe</1> or any Kilovolt compatible endpoint) for syncing keys and obtaining webhook events.",
"bind-placeholder": "HTTP endpoint, leave empty to disable integration",
"configuration": "Configuration",
"twitch-events": "Twitch events",
"err-not-enabled": "Please configure the back-end before accessing this page",
"loading-data": "Querying user data from backend…",
"authenticated-as": "Authenticated as",
"profile-picture": "Profile picture",
"err-no-user": "No twitch user is currently associated (and therefore webhooks are disabled!)",
"sim": {
"channel.update": "Channel update",
"channel.follow": "New follow",
"channel.subscribe": "New sub",
"channel.subscription.gift": "Gift sub",
"channel.subscription.message": "Re-sub with message",
"channel.cheer": "Cheer",
"channel.raid": "Raid"
},
"sim-events": "Send test event",
"auth-button": "Authenticate with Twitch",
"auth-message": "Click the following button to authenticate the back-end with your Twitch account:",
"current-status": "Current status"
},
"twitch-settings": {
"title": "Twitch configuration",
"enable": "Enable Twitch integration",
@ -86,6 +56,7 @@
"subtitle": "Twitch integration with streams including chat bot and API access. If you stream on Twitch, you definitely want this on.",
"api-subheader": "Application info",
"api-configuration": "API access",
"eventsub": "Events",
"bot-settings": "Bot settings",
"enable-bot": "Enable Twitch bot",
"bot-channel": "Twitch channel",
@ -95,7 +66,26 @@
"bot-info-header": "Bot account info",
"bot-settings-copy": "A bot can interact with chat messages and provide extra events for the platform (chat events, some notifications) but requires access to a Twitch account. You can use your own or make a new one (if enabled on your main account, you can re-use the same email for your second account!)",
"bot-chat-header": "Chat logging",
"bot-chat-history": "How many messages to keep in history (0 to disable)"
"bot-chat-history": "How many messages to keep in history (0 to disable)",
"events": {
"loading-data": "Querying user data from Twitch APIs…",
"authenticated-as": "Authenticated as",
"profile-picture": "Profile picture",
"err-no-user": "No twitch user is currently associated (and therefore events are disabled!)",
"sim": {
"channel.update": "Channel update",
"channel.follow": "New follow",
"channel.subscribe": "New sub",
"channel.subscription.gift": "Gift sub",
"channel.subscription.message": "Re-sub with message",
"channel.cheer": "Cheer",
"channel.raid": "Raid"
},
"sim-events": "Send test event",
"auth-button": "Authenticate with Twitch",
"auth-message": "Click the following button to authenticate the back-end with your Twitch account:",
"current-status": "Current status"
}
},
"botcommands": {
"title": "Bot commands",

View File

@ -5,7 +5,6 @@ import {
DashboardIcon,
FrameIcon,
GearIcon,
Link2Icon,
MixerHorizontalIcon,
StarIcon,
TableIcon,
@ -15,6 +14,7 @@ import { Route, Routes } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import { GetKilovoltBind } from '@wailsapp/go/main/App';
import { delay } from 'src/lib/time-utils';
import Dashboard from './pages/Dashboard';
import Sidebar, { RouteSection } from './components/Sidebar';
import ServerSettingsPage from './pages/ServerSettings';
@ -25,7 +25,6 @@ import { styled } from './theme';
// @ts-expect-error Asset import
import spinner from '../assets/icon-loading.svg';
import BackendIntegrationPage from './pages/BackendIntegration';
import TwitchSettingsPage from './pages/TwitchSettings';
import TwitchBotCommandsPage from './pages/BotCommands';
import TwitchBotTimersPage from './pages/BotTimers';
@ -74,11 +73,6 @@ const sections: RouteSection[] = [
url: '/http',
icon: <GearIcon />,
},
{
title: 'menu.pages.strimertul.stulbe',
url: '/backend',
icon: <Link2Icon />,
},
],
},
{
@ -156,7 +150,16 @@ export default function App(): JSX.Element {
const dispatch = useAppDispatch();
const connectToKV = async () => {
const address = await GetKilovoltBind();
let address = '';
while (address === '') {
// eslint-disable-next-line no-await-in-loop
address = await GetKilovoltBind();
if (address === '') {
// Server not ready yet, wait a second
// eslint-disable-next-line no-await-in-loop
await delay(1000);
}
}
await dispatch(
createWSClient({
address: `ws://${address}/ws`,
@ -191,7 +194,6 @@ export default function App(): JSX.Element {
<Route path="/about" element={<StrimertulPage />} />
<Route path="/debug" element={<DebugPage />} />
<Route path="/http" element={<ServerSettingsPage />} />
<Route path="/backend" element={<BackendIntegrationPage />} />
<Route path="/twitch/settings" element={<TwitchSettingsPage />} />
<Route
path="/twitch/bot/commands"

View File

@ -1,323 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { toast } from 'react-toastify';
import { ExternalLinkIcon } from '@radix-ui/react-icons';
import { useModule, useStatus } from '../../lib/react-utils';
import Stulbe from '../../lib/stulbe-lib';
import apiReducer, { modules } from '../../store/api/reducer';
import SaveButton from '../components/utils/SaveButton';
import {
Button,
ButtonGroup,
Field,
InputBox,
Label,
PageContainer,
PageHeader,
PageTitle,
SectionHeader,
styled,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from '../theme';
import eventsubTests from '../../data/eventsub-tests';
import { RootState, useAppDispatch } from '../../store';
import BrowserLink from '../components/BrowserLink';
interface UserData {
id: string;
login: string;
// eslint-disable-next-line camelcase
display_name: string;
// eslint-disable-next-line camelcase
profile_image_url: string;
}
interface SyncError {
ok: false;
error: string;
}
const TwitchUser = styled('div', {
display: 'flex',
gap: '0.8rem',
alignItems: 'center',
fontSize: '14pt',
fontWeight: '300',
});
const TwitchPic = styled('img', {
width: '48px',
borderRadius: '50%',
});
const TwitchName = styled('p', { fontWeight: 'bold' });
interface authChallengeRequest {
// eslint-disable-next-line camelcase
auth_url: string;
}
function WebhookIntegration() {
const { t } = useTranslation();
const [stulbeConfig] = useModule(modules.stulbeConfig);
const kv = useSelector((state: RootState) => state.api.client);
const [userStatus, setUserStatus] = useState<UserData | SyncError>(null);
const [client, setClient] = useState<Stulbe>(null);
const getUserInfo = async () => {
try {
const res = await client.makeRequest<UserData, null>(
'GET',
'api/twitch/user',
);
setUserStatus(res);
} catch (e) {
setUserStatus({ ok: false, error: (e as Error).message });
}
};
const startAuthFlow = async () => {
const res = await client.makeRequest<authChallengeRequest, null>(
'POST',
'api/twitch/authorize',
);
const win = window.open(
res.auth_url,
'_blank',
'height=800,width=520,scrollbars=yes,status=yes',
);
// Hack, have to poll because no events are reliable for this
const iv = setInterval(() => {
if (win.closed) {
clearInterval(iv);
setUserStatus(null);
void getUserInfo();
}
}, 1000);
};
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
const data = eventsubTests[event];
await kv.putJSON('stulbe/ev/webhook', {
...data,
subscription: {
...data.subscription,
created_at: new Date().toISOString(),
},
});
};
// eslint-disable-next-line consistent-return
useEffect(() => {
if (client) {
// Get user info
void getUserInfo();
} else if (
stulbeConfig &&
stulbeConfig.enabled &&
stulbeConfig.endpoint &&
stulbeConfig.auth_key &&
stulbeConfig.username
) {
const tryAuth = async () => {
// Try authenticating
const stulbeClient = new Stulbe(stulbeConfig.endpoint);
await stulbeClient.auth(stulbeConfig.username, stulbeConfig.auth_key);
setClient(stulbeClient);
};
void tryAuth();
}
}, [stulbeConfig, client]);
if (!stulbeConfig || !stulbeConfig.enabled) {
return <h1>{t('pages.stulbe.err-not-enabled')}</h1>;
}
let userBlock = <i>{t('pages.stulbe.loading-data')}</i>;
if (userStatus !== null) {
if ('id' in userStatus) {
userBlock = (
<>
<TwitchUser>
<p>{t('pages.stulbe.authenticated-as')}</p>
<TwitchPic
src={userStatus.profile_image_url}
alt={t('pages.stulbe.profile-picture')}
/>
<TwitchName>{userStatus.display_name}</TwitchName>
</TwitchUser>
</>
);
} else {
userBlock = <span>{t('pages.stulbe.err-no-user')}</span>;
}
}
return (
<>
<p>{t('pages.stulbe.auth-message')}</p>
<Button
variation="primary"
onClick={() => {
void startAuthFlow();
}}
disabled={!client}
>
<ExternalLinkIcon /> {t('pages.stulbe.auth-button')}
</Button>
<SectionHeader>{t('pages.stulbe.current-status')}</SectionHeader>
{userBlock}
<SectionHeader>{t('pages.stulbe.sim-events')}</SectionHeader>
<ButtonGroup>
{Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => (
<Button
key={ev}
onClick={() => {
void sendFakeEvent(ev);
}}
>
{t(`pages.stulbe.sim.${ev}`, { defaultValue: ev })}
</Button>
))}
</ButtonGroup>
</>
);
}
function BackendConfiguration() {
const [stulbeConfig, setStulbeConfig, loadStatus] = useModule(
modules.stulbeConfig,
);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const status = useStatus(loadStatus.save);
const active = stulbeConfig?.enabled ?? false;
const busy =
loadStatus.load?.type !== 'success' || loadStatus.save?.type === 'pending';
const test = async () => {
try {
const client = new Stulbe(stulbeConfig.endpoint);
await client.auth(stulbeConfig.username, stulbeConfig.auth_key);
toast.success(t('pages.stulbe.test-success'));
} catch (e) {
toast.error((e as Error).message);
}
};
return (
<form
onSubmit={(ev) => {
void dispatch(setStulbeConfig(stulbeConfig));
ev.preventDefault();
}}
>
<Field size="fullWidth">
<Label htmlFor="endpoint">{t('pages.stulbe.endpoint')}</Label>
<InputBox
type="text"
id="endpoint"
placeholder={t('pages.stulbe.bind-placeholder')}
value={stulbeConfig?.endpoint ?? ''}
disabled={busy}
onChange={(e) => {
void dispatch(
apiReducer.actions.stulbeConfigChanged({
...stulbeConfig,
enabled: e.target.value.length > 0,
endpoint: e.target.value,
}),
);
}}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="username">{t('pages.stulbe.username')}</Label>
<InputBox
type="text"
id="username"
value={stulbeConfig?.username ?? ''}
required={true}
disabled={!active || busy}
onChange={(e) => {
void dispatch(
apiReducer.actions.stulbeConfigChanged({
...stulbeConfig,
username: e.target.value,
}),
);
}}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="password">{t('pages.stulbe.auth-key')}</Label>
<InputBox
type="password"
id="password"
value={stulbeConfig?.auth_key ?? ''}
disabled={!active || busy}
required={true}
onChange={(e) => {
void dispatch(
apiReducer.actions.stulbeConfigChanged({
...stulbeConfig,
auth_key: e.target.value,
}),
);
}}
/>
</Field>
<ButtonGroup>
<SaveButton status={status} />
<Button
type="button"
disabled={!active || busy}
onClick={() => {
void test();
}}
>
{t('pages.stulbe.test-button')}
</Button>
</ButtonGroup>
</form>
);
}
export default function BackendIntegrationPage(): React.ReactElement {
const { t } = useTranslation();
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.stulbe.title')}</PageTitle>
<TextBlock>
<Trans i18nKey="pages.stulbe.subtitle">
{' '}
<BrowserLink href="https://github.com/strimertul/stulbe/">
stulbe
</BrowserLink>
</Trans>
</TextBlock>
</PageHeader>
<TabContainer defaultValue="configuration">
<TabList>
<TabButton value="configuration">
{t('pages.stulbe.configuration')}
</TabButton>
<TabButton value="webhook">
{t('pages.stulbe.twitch-events')}
</TabButton>
</TabList>
<TabContent value="configuration">
<BackendConfiguration />
</TabContent>
<TabContent value="webhook">
<WebhookIntegration />
</TabContent>
</TabContainer>
</PageContainer>
);
}

View File

@ -1,7 +1,8 @@
import React from 'react';
import React, { useState } from 'react';
import { keyframes } from '@stitches/react';
import { Trans, useTranslation } from 'react-i18next';
import { GitHubLogoIcon, TwitterLogoIcon } from '@radix-ui/react-icons';
import { useNavigate } from 'react-router-dom';
import { APPNAME, PageContainer, PageHeader, styled } from '../theme';
import BrowserLink from '../components/BrowserLink';
@ -77,7 +78,17 @@ const ChannelLink = styled(BrowserLink, {
});
export default function StrimertulPage(): React.ReactElement {
const navigate = useNavigate();
const { t } = useTranslation();
const [debugCount, setDebugCount] = useState(0);
const countForDebug = () => {
if (debugCount < 5) {
setDebugCount(debugCount+1);
} else {
navigate("/debug");
}
};
return (
<PageContainer>
<PageHeader
@ -96,6 +107,7 @@ export default function StrimertulPage(): React.ReactElement {
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
}}
onClick={countForDebug}
/>
<LogoName>{APPNAME}</LogoName>
</PageHeader>

View File

@ -1,13 +1,20 @@
import { CheckIcon } from '@radix-ui/react-icons';
import React from 'react';
import { CheckIcon, ExternalLinkIcon } from '@radix-ui/react-icons';
import { GetTwitchAuthURL, GetTwitchLoggedUser } from '@wailsapp/go/main/App';
import { helix } from '@wailsapp/go/models';
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
import React, { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import eventsubTests from '../../data/eventsub-tests';
import { useModule, useStatus } from '../../lib/react-utils';
import { useAppDispatch } from '../../store';
import { RootState, useAppDispatch } from '../../store';
import apiReducer, { modules } from '../../store/api/reducer';
import BrowserLink from '../components/BrowserLink';
import DefinitionTable from '../components/DefinitionTable';
import SaveButton from '../components/utils/SaveButton';
import {
Button,
ButtonGroup,
Checkbox,
CheckboxIndicator,
Field,
@ -221,7 +228,7 @@ function TwitchAPISettings() {
httpConfig?.bind.indexOf(':') > 0
? httpConfig.bind
: `localhost${httpConfig?.bind ?? ':4337'}`
}/oauth`,
}/twitch/callback`,
Category: 'Broadcasting Suite',
}}
/>
@ -279,6 +286,111 @@ function TwitchAPISettings() {
);
}
interface SyncError {
ok: false;
error: string;
}
const TwitchUser = styled('div', {
display: 'flex',
gap: '0.8rem',
alignItems: 'center',
fontSize: '14pt',
fontWeight: '300',
});
const TwitchPic = styled('img', {
width: '48px',
borderRadius: '50%',
});
const TwitchName = styled('p', { fontWeight: 'bold' });
function TwitchEventSubSettings() {
const { t } = useTranslation();
const [userStatus, setUserStatus] = useState<helix.User | SyncError>(null);
const kv = useSelector((state: RootState) => state.api.client);
const getUserInfo = async () => {
try {
const res = await GetTwitchLoggedUser();
setUserStatus(res);
} catch (e) {
console.error(e);
setUserStatus({ ok: false, error: (e as Error).message });
}
};
const startAuthFlow = async () => {
const url = await GetTwitchAuthURL();
BrowserOpenURL(url);
};
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
const data = eventsubTests[event];
await kv.putJSON('twitch/ev/eventsub-event', {
...data,
subscription: {
...data.subscription,
created_at: new Date().toISOString(),
},
});
};
useEffect(() => {
// Get user info
void getUserInfo();
}, []);
let userBlock = <i>{t('pages.twitch-settings.events.loading-data')}</i>;
if (userStatus !== null) {
if ('id' in userStatus) {
userBlock = (
<>
<TwitchUser>
<p>{t('pages.twitch-settings.events.authenticated-as')}</p>
<TwitchPic
src={userStatus.profile_image_url}
alt={t('pages.twitch-settings.events.profile-picture')}
/>
<TwitchName>{userStatus.display_name}</TwitchName>
</TwitchUser>
</>
);
} else {
userBlock = <span>{t('pages.twitch-settings.events.err-no-user')}</span>;
}
}
return (
<>
<p>{t('pages.twitch-settings.events.auth-message')}</p>
<Button
variation="primary"
onClick={() => {
void startAuthFlow();
}}
>
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
</Button>
<SectionHeader>{t('pages.twitch-settings.events.current-status')}</SectionHeader>
{userBlock}
<SectionHeader>{t('pages.twitch-settings.events.sim-events')}</SectionHeader>
<ButtonGroup>
{Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => (
<Button
key={ev}
onClick={() => {
void sendFakeEvent(ev);
}}
>
{t(`pages.twitch-settings.events.sim.${ev}`, { defaultValue: ev })}
</Button>
))}
</ButtonGroup>
</>
);
}
export default function TwitchSettingsPage(): React.ReactElement {
const { t } = useTranslation();
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
@ -318,6 +430,9 @@ export default function TwitchSettingsPage(): React.ReactElement {
<TabButton value="api-config">
{t('pages.twitch-settings.api-configuration')}
</TabButton>
<TabButton value="eventsub">
{t('pages.twitch-settings.eventsub')}
</TabButton>
<TabButton value="bot-settings">
{t('pages.twitch-settings.bot-settings')}
</TabButton>
@ -325,6 +440,9 @@ export default function TwitchSettingsPage(): React.ReactElement {
<TabContent value="api-config">
<TwitchAPISettings />
</TabContent>
<TabContent value="eventsub">
<TwitchEventSubSettings />
</TabContent>
<TabContent value="bot-settings">
<TwitchBotSettings />
</TabContent>

View File

@ -1,8 +1,13 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {helix} from '../models';
export function AuthenticateKVClient(arg1:string):Promise<void>;
export function GetKilovoltBind():Promise<string>;
export function GetTwitchAuthURL():Promise<string>;
export function GetTwitchLoggedUser():Promise<helix.User>;
export function IsServerReady():Promise<boolean>;

View File

@ -10,6 +10,14 @@ export function GetKilovoltBind() {
return window['go']['main']['App']['GetKilovoltBind']();
}
export function GetTwitchAuthURL() {
return window['go']['main']['App']['GetTwitchAuthURL']();
}
export function GetTwitchLoggedUser() {
return window['go']['main']['App']['GetTwitchLoggedUser']();
}
export function IsServerReady() {
return window['go']['main']['App']['IsServerReady']();
}

View File

@ -0,0 +1,56 @@
export namespace helix {
export class User {
id: string;
login: string;
display_name: string;
type: string;
broadcaster_type: string;
description: string;
profile_image_url: string;
offline_image_url: string;
view_count: number;
email: string;
// Go type: Time
created_at: any;
static createFrom(source: any = {}) {
return new User(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.login = source["login"];
this.display_name = source["display_name"];
this.type = source["type"];
this.broadcaster_type = source["broadcaster_type"];
this.description = source["description"];
this.profile_image_url = source["profile_image_url"];
this.offline_image_url = source["offline_image_url"];
this.view_count = source["view_count"];
this.email = source["email"];
this.created_at = this.convertValues(source["created_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}

9
go.mod
View File

@ -7,17 +7,20 @@ require (
github.com/Masterminds/sprig/v3 v3.2.2
github.com/cockroachdb/pebble v0.0.0-20221116223310-87eccabb90a3
github.com/gempir/go-twitch-irc/v3 v3.2.0
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/golang-lru v0.5.1
github.com/json-iterator/go v1.1.12
github.com/labstack/gommon v0.3.1
github.com/nicklaw5/helix/v2 v2.11.0
github.com/strimertul/kilovolt/v9 v9.0.1
github.com/strimertul/kv-pebble v1.2.0
github.com/strimertul/stulbe-client-go v0.7.2
github.com/urfave/cli/v2 v2.23.5
github.com/wailsapp/wails/v2 v2.2.0
go.uber.org/zap v1.23.0
)
replace github.com/nicklaw5/helix/v2 => github.com/ashkeel/helix/v2 v2.11.0-ws
require (
github.com/DataDog/zstd v1.5.2 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
@ -36,7 +39,6 @@ require (
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/huandu/xstrings v1.3.1 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
@ -54,7 +56,6 @@ require (
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc // indirect
github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
@ -66,8 +67,6 @@ require (
github.com/samber/lo v1.27.1 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/strimertul/kilovolt-client-go/v8 v8.0.0 // indirect
github.com/strimertul/kilovolt/v8 v8.0.5 // indirect
github.com/tkrajina/go-reflector v0.5.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect

18
go.sum
View File

@ -58,6 +58,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/ashkeel/helix/v2 v2.11.0-ws h1:AG2mWRs7qfhigv+UcNDkjJaSSbtiJIM1njlwLu7FVa4=
github.com/ashkeel/helix/v2 v2.11.0-ws/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@ -222,11 +224,11 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@ -353,9 +355,6 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4=
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nicklaw5/helix/v2 v2.2.0/go.mod h1:0ONzvVi1cH+k3a7EDIFNNqxfW0podhf+CqlmFvuexq8=
github.com/nicklaw5/helix/v2 v2.11.0 h1:jndQ+R/Z+C/hFf5uzy2uKRBU+/dCAYRVNBH669QH47c=
github.com/nicklaw5/helix/v2 v2.11.0/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -363,8 +362,6 @@ github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108
github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc h1:Ak86L+yDSOzKFa7WM5bf5itSOo1e3Xh8bm5YCMUXIjQ=
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
@ -445,17 +442,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/strimertul/kilovolt-client-go/v8 v8.0.0 h1:d3BAm5qavK9GPUpOtljpsyrjmSfR2AInGe1ypZP9apc=
github.com/strimertul/kilovolt-client-go/v8 v8.0.0/go.mod h1:PNEbu0zrdYD9B9UYUoLSpV+saRJlC0cr9OHdPALUb+o=
github.com/strimertul/kilovolt/v8 v8.0.0/go.mod h1:vW++ELCWnYzENIIP33p+zDGQjz/GpQ5z7YRCBrBtCzA=
github.com/strimertul/kilovolt/v8 v8.0.5 h1:m3b6OK34qLywS+zhQFWoZJpRf9ImiLCWNIFhSFrIsdw=
github.com/strimertul/kilovolt/v8 v8.0.5/go.mod h1:rGfAix+UFEUTlwcI2BtHpwZ2JsqfOKZiexf8TkOUBwQ=
github.com/strimertul/kilovolt/v9 v9.0.1 h1:T915LgdbJpu3XZ4Idt+Tvv7hDW1wmSDmQCDj4696bUY=
github.com/strimertul/kilovolt/v9 v9.0.1/go.mod h1:i9cizfUV9B+XYkmLSPr2dhNe8kt4R0xjG2kCZb7XoZg=
github.com/strimertul/kv-pebble v1.2.0 h1:hX4bp1CntBvBwfjDjlpWrRYb1+JouMGUjv9e+7STcPI=
github.com/strimertul/kv-pebble v1.2.0/go.mod h1:FGLXP0Wagoz/JSZITG0PAlLvUdCE6bbCOzbdmqU45JY=
github.com/strimertul/stulbe-client-go v0.7.2 h1:mco2JjkYuahgq1p8nlH7TRWNgFKyQMPb83AnBNO6B6E=
github.com/strimertul/stulbe-client-go v0.7.2/go.mod h1:moBqGVP+6cDkJM760YUhSuLgrenHsRRgnC6s+91TzSs=
github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
github.com/tkrajina/go-reflector v0.5.5 h1:gwoQFNye30Kk7NrExj8zm3zFtrGPqOkzFMLuQZg1DtQ=
github.com/tkrajina/go-reflector v0.5.5/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
@ -507,7 +497,6 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.20.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@ -847,7 +836,6 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

@ -19,7 +19,6 @@ import (
"github.com/strimertul/strimertul/modules"
"github.com/strimertul/strimertul/modules/loyalty"
"github.com/strimertul/strimertul/modules/stulbe"
"github.com/strimertul/strimertul/modules/twitch"
_ "net/http/pprof"
@ -39,7 +38,6 @@ var frontend embed.FS
type ModuleConstructor = func(manager *modules.Manager) error
var moduleList = map[modules.ModuleID]ModuleConstructor{
modules.ModuleStulbe: stulbe.Register,
modules.ModuleLoyalty: loyalty.Register,
modules.ModuleTwitch: twitch.Register,
}

View File

@ -9,6 +9,8 @@ import (
"net/http"
"net/http/pprof"
"github.com/strimertul/strimertul/modules/twitch"
"git.sr.ht/~hamcha/containers"
jsoniter "github.com/json-iterator/go"
@ -30,6 +32,7 @@ type Server struct {
frontend fs.FS
hub *kv.Hub
mux *http.ServeMux
manager *modules.Manager
}
func NewServer(manager *modules.Manager) (*Server, error) {
@ -47,9 +50,10 @@ func NewServer(manager *modules.Manager) (*Server, error) {
}
server := &Server{
logger: logger,
db: db,
server: &http.Server{},
logger: logger,
db: db,
server: &http.Server{},
manager: manager,
}
err = db.GetJSON(ServerConfigKey, &server.Config)
if err != nil {
@ -125,6 +129,9 @@ func (s *Server) makeMux() *http.ServeMux {
if s.Config.EnableStaticServer {
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.Config.Path))))
}
if s.manager.Modules[modules.ModuleTwitch].Status().Enabled {
mux.HandleFunc("/twitch/callback", s.manager.Modules[modules.ModuleTwitch].(*twitch.Client).AuthorizeCallback)
}
return mux
}

View File

@ -9,7 +9,6 @@ import (
"github.com/strimertul/strimertul/modules"
"github.com/strimertul/strimertul/modules/database"
"github.com/strimertul/strimertul/modules/stulbe"
jsoniter "github.com/json-iterator/go"
"go.uber.org/zap"
@ -94,22 +93,6 @@ func Register(manager *modules.Manager) error {
// Subscribe for changes
go db.Subscribe(loyalty.update, "loyalty/")
go db.Subscribe(loyalty.handleRemote, "stulbe/loyalty/")
// Replicate keys on stulbe if available
if stulbeManager, ok := manager.Modules["stulbe"].(*stulbe.Manager); ok {
go func() {
err := stulbeManager.ReplicateKeys([]string{
ConfigKey,
RewardsKey,
GoalsKey,
PointsPrefix,
})
if err != nil {
logger.Error("failed to replicate keys", zap.Error(err))
}
}()
}
// Register module
manager.Modules[modules.ModuleLoyalty] = loyalty
@ -201,54 +184,6 @@ func (m *Manager) update(key, value string) {
}
}
func (m *Manager) handleRemote(key, value string) {
m.logger.Debug("loyalty request from stulbe", zap.String("key", key))
switch key {
case KVExLoyaltyRedeem:
// Parse request
var redeemRequest ExLoyaltyRedeem
err := json.UnmarshalFromString(value, &redeemRequest)
if err != nil {
m.logger.Warn("error decoding redeem request", zap.Error(err))
break
}
// Find reward
reward := m.GetReward(redeemRequest.RewardID)
if reward.ID == "" {
m.logger.Warn("redeem request contains invalid reward id", zap.String("reward-id", redeemRequest.RewardID))
break
}
err = m.PerformRedeem(Redeem{
Username: redeemRequest.Username,
DisplayName: redeemRequest.DisplayName,
Reward: reward,
When: time.Now(),
RequestText: redeemRequest.RequestText,
})
if err != nil {
m.logger.Warn("error performing redeem request", zap.Error(err))
}
case KVExLoyaltyContribute:
// Parse request
var contributeRequest ExLoyaltyContribute
err := json.UnmarshalFromString(value, &contributeRequest)
if err != nil {
m.logger.Warn("error decoding contribution request", zap.Error(err))
break
}
// Find goal
goal := m.GetGoal(contributeRequest.GoalID)
if goal.ID == "" {
m.logger.Warn("contribute request contains invalid goal id", zap.String("goal-id", contributeRequest.GoalID))
break
}
err = m.PerformContribution(goal, contributeRequest.Username, contributeRequest.Amount)
if err != nil {
m.logger.Warn("error performing contribution request", zap.Error(err))
}
}
}
func (m *Manager) GetPoints(user string) int64 {
m.mu.Lock()
defer m.mu.Unlock()

View File

@ -1,179 +0,0 @@
package stulbe
import (
"encoding/json"
"errors"
"github.com/strimertul/strimertul/modules/database"
"go.uber.org/zap"
"github.com/strimertul/strimertul/modules"
"github.com/strimertul/stulbe-client-go"
)
type Manager struct {
Config Config
Client *stulbe.Client
db *database.DBModule
logger *zap.Logger
restart chan bool
}
func Register(manager *modules.Manager) error {
db, ok := manager.Modules["db"].(*database.DBModule)
if !ok {
return errors.New("db module not found")
}
logger := manager.Logger(modules.ModuleStulbe)
var config Config
err := db.GetJSON(ConfigKey, &config)
if err != nil {
return err
}
// Create client
stulbeClient, err := stulbe.NewClient(stulbe.ClientOptions{
Endpoint: config.Endpoint,
Username: config.Username,
AuthKey: config.AuthKey,
})
if err != nil {
return err
}
// Create manager
stulbeManager := &Manager{
Config: config,
Client: stulbeClient,
db: db,
logger: logger,
restart: make(chan bool),
}
// Receive key updates
go func() {
for {
err := stulbeManager.ReceiveEvents()
if err != nil {
logger.Error("Stulbe subscription died unexpectedly!", zap.Error(err))
// Wait for config change before retrying
<-stulbeManager.restart
}
}
}()
// Listen for config changes
go db.Subscribe(func(key, value string) {
if key == ConfigKey {
var config Config
err := json.Unmarshal([]byte(value), &config)
if err != nil {
logger.Warn("Failed to get new config", zap.Error(err))
return
}
client, err := stulbe.NewClient(stulbe.ClientOptions{
Endpoint: config.Endpoint,
Username: config.Username,
AuthKey: config.AuthKey,
})
if err != nil {
logger.Warn("Failed to update stulbe client, keeping old settings", zap.Error(err))
} else {
stulbeManager.Client.Close()
stulbeManager.Client = client
stulbeManager.restart <- true
logger.Info("updated/restarted stulbe client")
}
}
}, ConfigKey)
// Register module
manager.Modules[modules.ModuleStulbe] = stulbeManager
return nil
}
func (m *Manager) ReceiveEvents() error {
chn, err := m.Client.KV.SubscribePrefix("stulbe/")
if err != nil {
return err
}
for {
select {
case kv := <-chn:
err := m.db.PutKey(kv.Key, kv.Value)
if err != nil {
return err
}
case <-m.restart:
return nil
}
}
}
func (m *Manager) Status() modules.ModuleStatus {
if !m.Config.Enabled {
return modules.ModuleStatus{
Enabled: false,
}
}
return modules.ModuleStatus{
Enabled: true,
Working: m.Client != nil,
Data: struct{}{},
StatusString: "",
}
}
func (m *Manager) Close() error {
m.Client.Close()
return nil
}
func (m *Manager) ReplicateKey(prefix string) error {
// Set key to current value
vals, err := m.db.GetAll(prefix)
if err != nil {
return err
}
// Add prefix to keys
newvals := make(map[string]string)
for k, v := range vals {
newvals[prefix+k] = v
}
err = m.Client.KV.SetKeys(newvals)
if err != nil {
return err
}
m.logger.Debug("synced to remote", zap.String("prefix", prefix))
// Subscribe to local datastore and update remote on change
return m.db.Subscribe(func(key, value string) {
err := m.Client.KV.SetKey(key, value)
if err != nil {
m.logger.Error("failed to replicate key", zap.String("key", key), zap.Error(err))
} else {
m.logger.Debug("replicated to remote", zap.String("key", key))
}
}, prefix)
}
func (m *Manager) ReplicateKeys(prefixes []string) error {
for _, prefix := range prefixes {
err := m.ReplicateKey(prefix)
if err != nil {
return err
}
}
return nil
}

View File

@ -1,16 +0,0 @@
package stulbe
import "errors"
const ConfigKey = "stulbe/config"
type Config struct {
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Username string `json:"username"`
AuthKey string `json:"auth_key"`
}
var (
ErrNotAuthenticated = errors.New("not authenticated")
)

View File

@ -0,0 +1,104 @@
package twitch
import (
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/nicklaw5/helix/v2"
)
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope []string `json:"scope"`
Time time.Time
}
func (c *Client) GetAuthorizationURL() string {
return c.API.GetAuthorizationURL(&helix.AuthorizationURLParams{
ResponseType: "code",
Scopes: []string{"bits:read channel:read:subscriptions channel:read:redemptions channel:read:polls channel:read:predictions channel:read:hype_train user_read"},
})
}
func (c *Client) GetLoggedUser() (helix.User, error) {
var authResp AuthResponse
err := c.db.GetJSON(AuthKey, &authResp)
if err != nil {
return helix.User{}, err
}
client, err := helix.NewClient(&helix.Options{
ClientID: c.Config.APIClientID,
ClientSecret: c.Config.APIClientSecret,
UserAccessToken: authResp.AccessToken,
})
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")
}
return users.Data.Users[0], nil
}
func (c *Client) AuthorizeCallback(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
}
redirectURI, err := c.getRedirectURI()
if err != nil {
http.Error(w, "failed getting redirect uri", http.StatusInternalServerError)
return
}
// Exchange code for access/refresh tokens
query := url.Values{
"client_id": {c.Config.APIClientID},
"client_secret": {c.Config.APIClientSecret},
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
}
authRequest, err := http.NewRequest("POST", "https://id.twitch.tv/oauth2/token?"+query.Encode(), nil)
if err != nil {
http.Error(w, "failed creating auth request: "+err.Error(), http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(authRequest)
if err != nil {
http.Error(w, "failed sending auth request: "+err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var authResp AuthResponse
err = json.NewDecoder(resp.Body).Decode(&authResp)
if err != nil && err != io.EOF {
http.Error(w, "failed reading auth response: "+err.Error(), http.StatusInternalServerError)
return
}
authResp.Time = time.Now()
err = c.db.PutJSON(AuthKey, authResp)
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>`)
}
func (c *Client) getRedirectURI() (string, error) {
var severConfig struct {
Bind string `json:"bind"`
}
err := c.db.GetJSON("http/config", &severConfig)
return fmt.Sprintf("http://%s/twitch/callback", severConfig.Bind), err
}

View File

@ -1 +1,228 @@
package twitch
import (
"fmt"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"github.com/nicklaw5/helix/v2"
)
const websocketEndpoint = "wss://eventsub-beta.wss.twitch.tv/ws"
func (c *Client) connectWebsocket() error {
connection, _, err := websocket.DefaultDialer.Dial(websocketEndpoint, nil)
if err != nil {
c.logger.Error("could not connect to eventsub ws", zap.Error(err))
return fmt.Errorf("error connecting to websocket server: %w", err)
}
for {
messageType, messageData, err := connection.ReadMessage()
if err != nil {
c.logger.Warn("eventsub ws read error", zap.Error(err))
break
}
if messageType != websocket.TextMessage {
continue
}
var wsMessage EventSubWebsocketMessage
err = json.Unmarshal(messageData, &wsMessage)
if err != nil {
c.logger.Error("eventsub ws decode error", zap.Error(err))
continue
}
switch wsMessage.Metadata.MessageType {
case "session_keepalive":
// Nothing to do
case "session_welcome":
var welcomeData WelcomeMessagePayload
err = json.Unmarshal(wsMessage.Payload, &welcomeData)
if err != nil {
c.logger.Error("eventsub ws decode error", zap.String("message-type", wsMessage.Metadata.MessageType), zap.Error(err))
}
c.logger.Info("eventsub ws connection established", zap.String("session-id", welcomeData.Session.Id))
// Add subscription to websocket session
err = c.addSubscriptionsForSession(welcomeData.Session.Id)
if err != nil {
c.logger.Error("could not add subscriptions", zap.Error(err))
}
case "session_reconnect":
var reconnectData WelcomeMessagePayload
err = json.Unmarshal(wsMessage.Payload, &reconnectData)
if err != nil {
c.logger.Error("eventsub ws decode error", zap.String("message-type", wsMessage.Metadata.MessageType), zap.Error(err))
}
c.logger.Info("eventsub ws connection reset requested", zap.String("session-id", reconnectData.Session.Id), zap.String("reconnect-url", reconnectData.Session.ReconnectUrl))
// Try reconnecting to the new URL
newConnection, _, err := websocket.DefaultDialer.Dial(reconnectData.Session.ReconnectUrl, nil)
if err != nil {
c.logger.Error("eventsub ws reconnect error", zap.Error(err))
} else {
_ = connection.Close()
connection = newConnection
}
case "notification":
go c.processEvent(wsMessage)
case "revocation":
// TODO idk what to do here
}
}
return connection.Close()
}
func (c *Client) processEvent(message EventSubWebsocketMessage) {
// Check if we processed this already
if message.Metadata.MessageId != "" {
if c.eventCache.Contains(message.Metadata.MessageId) {
c.logger.Debug("Received duplicate event, ignoring", zap.String("message-id", message.Metadata.MessageId))
return
}
}
defer c.eventCache.Add(message.Metadata.MessageId, message.Metadata.MessageTimestamp)
// Decode data
var notificationData NotificationMessagePayload
err := json.Unmarshal(message.Payload, &notificationData)
if err != nil {
c.logger.Error("eventsub ws decode error", zap.String("message-type", message.Metadata.MessageType), zap.Error(err))
}
err = c.db.PutJSON(EventSubEventKey, notificationData)
if err != nil {
c.logger.Error("error saving event to db", zap.String("key", EventSubEventKey), zap.Error(err))
}
var archive []NotificationMessagePayload
err = c.db.GetJSON(EventSubHistoryKey, &archive)
if err != nil {
archive = []NotificationMessagePayload{}
}
archive = append(archive, notificationData)
if len(archive) > EventSubHistorySize {
archive = archive[len(archive)-EventSubHistorySize:]
}
err = c.db.PutJSON(EventSubHistoryKey, archive)
if err != nil {
c.logger.Error("error saving event to db", zap.String("key", EventSubHistoryKey), zap.Error(err))
}
}
func (c *Client) addSubscriptionsForSession(session string) error {
var authResp AuthResponse
err := c.db.GetJSON(AuthKey, &authResp)
if err != nil {
return err
}
client, err := helix.NewClient(&helix.Options{
ClientID: c.Config.APIClientID,
ClientSecret: c.Config.APIClientSecret,
UserAccessToken: authResp.AccessToken,
})
users, err := client.GetUsers(&helix.UsersParams{})
if err != nil {
return fmt.Errorf("failed looking up user: %w", err)
}
if len(users.Data.Users) < 1 {
return fmt.Errorf("no users found")
}
user := users.Data.Users[0]
transport := helix.EventSubTransport{
Method: "websocket",
SessionID: session,
}
for topic, version := range subscriptionVersions {
sub, err := client.CreateEventSubSubscription(&helix.EventSubSubscription{
Type: topic,
Version: version,
Status: "enabled",
Transport: transport,
Condition: topicCondition(topic, user.ID),
})
if sub.Error != "" || sub.ErrorMessage != "" {
c.logger.Error("subscription error", zap.String("err", sub.Error), zap.String("message", sub.ErrorMessage))
return fmt.Errorf("%s: %s", sub.Error, sub.ErrorMessage)
}
if err != nil {
return fmt.Errorf("error subscribing to %s: %w", topic, err)
}
}
return nil
}
func topicCondition(topic string, id string) helix.EventSubCondition {
switch topic {
case "channel.raid":
return helix.EventSubCondition{
ToBroadcasterUserID: id,
}
default:
return helix.EventSubCondition{
BroadcasterUserID: id,
}
}
}
type EventSubWebsocketMessage struct {
Metadata EventSubMetadata `json:"metadata"`
Payload jsoniter.RawMessage `json:"payload"`
}
type WelcomeMessagePayload struct {
Session struct {
Id string `json:"id"`
Status string `json:"status"`
ConnectedAt time.Time `json:"connected_at"`
KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"`
ReconnectUrl string `json:"reconnect_url,omitempty"`
} `json:"session"`
}
type NotificationMessagePayload struct {
Subscription helix.EventSubSubscription
Event jsoniter.RawMessage `json:"event"`
}
type EventSubMetadata struct {
MessageId string `json:"message_id"`
MessageType string `json:"message_type"`
MessageTimestamp time.Time `json:"message_timestamp"`
SubscriptionType string `json:"subscription_type"`
SubscriptionVersion string `json:"subscription_version"`
}
var subscriptionVersions = map[string]string{
helix.EventSubTypeChannelUpdate: "1",
helix.EventSubTypeChannelFollow: "1",
helix.EventSubTypeChannelSubscription: "1",
helix.EventSubTypeChannelSubscriptionGift: "1",
helix.EventSubTypeChannelSubscriptionMessage: "1",
helix.EventSubTypeChannelCheer: "1",
helix.EventSubTypeChannelRaid: "1",
helix.EventSubTypeChannelPollBegin: "1",
helix.EventSubTypeChannelPollProgress: "1",
helix.EventSubTypeChannelPollEnd: "1",
helix.EventSubTypeChannelPredictionBegin: "1",
helix.EventSubTypeChannelPredictionProgress: "1",
helix.EventSubTypeChannelPredictionLock: "1",
helix.EventSubTypeChannelPredictionEnd: "1",
helix.EventSubTypeHypeTrainBegin: "1",
helix.EventSubTypeHypeTrainProgress: "1",
helix.EventSubTypeHypeTrainEnd: "1",
helix.EventSubTypeChannelPointsCustomRewardAdd: "1",
helix.EventSubTypeChannelPointsCustomRewardUpdate: "1",
helix.EventSubTypeChannelPointsCustomRewardRemove: "1",
helix.EventSubTypeChannelPointsCustomRewardRedemptionAdd: "1",
helix.EventSubTypeChannelPointsCustomRewardRedemptionUpdate: "1",
helix.EventSubTypeStreamOnline: "1",
helix.EventSubTypeStreamOffline: "1",
}

View File

@ -6,6 +6,7 @@ import (
"time"
"git.sr.ht/~hamcha/containers"
lru "github.com/hashicorp/golang-lru"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"github.com/strimertul/strimertul/modules/database"
@ -18,11 +19,13 @@ import (
var json = jsoniter.ConfigFastest
type Client struct {
Config Config
Bot *Bot
db *database.DBModule
API *helix.Client
logger *zap.Logger
Config Config
Bot *Bot
db *database.DBModule
API *helix.Client
logger *zap.Logger
manager *modules.Manager
eventCache *lru.Cache
restart chan bool
streamOnline *containers.RWSync[bool]
@ -36,9 +39,14 @@ func Register(manager *modules.Manager) error {
logger := manager.Logger(modules.ModuleTwitch)
eventCache, err := lru.New(128)
if err != nil {
return fmt.Errorf("could not create LRU cache for events: %w", err)
}
// Get Twitch config
var config Config
err := db.GetJSON(ConfigKey, &config)
err = db.GetJSON(ConfigKey, &config)
if err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return fmt.Errorf("failed to get twitch config: %w", err)
@ -47,52 +55,14 @@ func Register(manager *modules.Manager) error {
}
// Create Twitch client
var api *helix.Client
if config.Enabled {
api, err = getHelixAPI(config.APIClientID, config.APIClientSecret)
if err != nil {
return fmt.Errorf("failed to create twitch client: %w", err)
}
}
client := &Client{
Config: config,
db: db,
API: api,
logger: logger,
restart: make(chan bool, 128),
streamOnline: containers.NewRWSync(false),
}
if client.Config.EnableBot {
if err := client.startBot(manager); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return err
}
}
}
go client.runStatusPoll()
go func() {
for {
if client.Config.EnableBot && client.Bot != nil {
err := client.RunBot()
if err != nil {
logger.Error("failed to connect to Twitch IRC", zap.Error(err))
// Wait for config change before retrying
<-client.restart
}
} else {
<-client.restart
}
}
}()
// If loyalty module is enabled, set-up loyalty commands
if loyaltyManager, ok := manager.Modules[modules.ModuleLoyalty].(*loyalty.Manager); ok && client.Bot != nil {
client.Bot.SetupLoyalty(loyaltyManager)
manager: manager,
eventCache: eventCache,
}
// Listen for config changes
@ -104,12 +74,13 @@ func Register(manager *modules.Manager) error {
logger.Error("failed to unmarshal config", zap.Error(err))
return
}
api, err := getHelixAPI(config.APIClientID, config.APIClientSecret)
api, err := client.getHelixAPI()
if err != nil {
logger.Warn("failed to create new twitch client, keeping old credentials", zap.Error(err))
return
}
client.API = api
logger.Info("reloaded/updated Twitch API")
case BotConfigKey:
var twitchBotConfig BotConfig
@ -134,8 +105,46 @@ func Register(manager *modules.Manager) error {
}
}, ConfigKey, BotConfigKey)
if config.Enabled {
client.API, err = client.getHelixAPI()
if err != nil {
client.logger.Error("failed to create twitch client", zap.Error(err))
}
}
if client.Config.EnableBot {
if err := client.startBot(manager); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return err
}
}
}
go client.runStatusPoll()
go client.connectWebsocket()
go func() {
for {
if client.Config.EnableBot && client.Bot != nil {
err := client.RunBot()
if err != nil {
logger.Error("failed to connect to Twitch IRC", zap.Error(err))
// Wait for config change before retrying
<-client.restart
}
} else {
<-client.restart
}
}
}()
manager.Modules[modules.ModuleTwitch] = client
// If loyalty module is enabled, set-up loyalty commands
if loyaltyManager, ok := client.manager.Modules[modules.ModuleLoyalty].(*loyalty.Manager); ok && client.Bot != nil {
client.Bot.SetupLoyalty(loyaltyManager)
}
return nil
}
@ -191,11 +200,17 @@ func (c *Client) startBot(manager *modules.Manager) error {
return nil
}
func getHelixAPI(clientID string, clientSecret string) (*helix.Client, error) {
func (c *Client) getHelixAPI() (*helix.Client, error) {
redirectURI, err := c.getRedirectURI()
if err != nil {
return nil, err
}
// Create Twitch client
api, err := helix.NewClient(&helix.Options{
ClientID: clientID,
ClientSecret: clientSecret,
ClientID: c.Config.APIClientID,
ClientSecret: c.Config.APIClientSecret,
RedirectURI: redirectURI,
})
if err != nil {
return nil, err

View File

@ -38,3 +38,12 @@ const CustomCommandsKey = "twitch/bot-custom-commands"
const WriteMessageRPC = "twitch/@send-chat-message"
const BotCounterPrefix = "twitch/bot-counters/"
const AuthKey = "twitch/auth-keys"
const (
EventSubEventKey = "twitch/ev/eventsub-event"
EventSubHistoryKey = "twitch/eventsub-history"
)
const EventSubHistorySize = 50