mirror of https://git.sr.ht/~ashkeel/strimertul
feat: add eventsub websocket, remove stulbe
This commit is contained in:
parent
28c275a553
commit
44333fc392
21
app.go
21
app.go
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
24591b6315c04d57021b01b6fdb4e73f
|
||||
1de5a15d31eda16cf49dfabfe8142773
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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']();
|
||||
}
|
||||
|
|
|
@ -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
9
go.mod
|
@ -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
18
go.sum
|
@ -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=
|
||||
|
|
2
main.go
2
main.go
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
)
|
|
@ -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
|
||||
}
|
|
@ -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, ¬ificationData)
|
||||
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",
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue