1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-30 02:40:33 +00:00

Compare commits

..

No commits in common. "07e3a00990ccfee1854cc7a0c312088fac130794" and "bc83a743f3dc6da9e56f97bdb6adb4c739f4cb6c" have entirely different histories.

17 changed files with 219 additions and 261 deletions

24
app.go
View file

@ -241,26 +241,12 @@ func (a *App) GetKilovoltBind() string {
return a.httpServer.Config.Get().Bind return a.httpServer.Config.Get().Bind
} }
func (a *App) GetTwitchAuthURL(state string) string { func (a *App) GetTwitchAuthURL() string {
return twitch.GetAuthorizationURL(a.twitchManager.Client().API, state) return twitch.GetAuthorizationURL(a.twitchManager.Client().API)
} }
func (a *App) GetTwitchLoggedUser(key string) (helix.User, error) { func (a *App) GetTwitchLoggedUser() (helix.User, error) {
userClient, err := twitch.GetUserClient(a.db, key, false) return a.twitchManager.Client().GetLoggedUser()
if err != nil {
return helix.User{}, err
}
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
return helix.User{}, err
}
if len(users.Data.Users) < 1 {
return helix.User{}, errors.New("no users found")
}
return users.Data.Users[0], nil
} }
func (a *App) GetLastLogs() []log.Entry { func (a *App) GetLastLogs() []log.Entry {
@ -355,7 +341,7 @@ func (a *App) interactiveAuth(client kv.Client, message map[string]any) bool {
func (a *App) showFatalError(err error, text string, fields ...any) { func (a *App) showFatalError(err error, text string, fields ...any) {
if err != nil { if err != nil {
fields = append(fields, log.ErrorSkip(err, 2), slog.String("Z", string(debug.Stack()))) fields = append(fields, log.Error(err), slog.String("Z", string(debug.Stack())))
slog.Error(text, fields...) slog.Error(text, fields...)
runtime.EventsEmit(a.ctx, "fatalError") runtime.EventsEmit(a.ctx, "fatalError")
a.isFatalError.Set(true) a.isFatalError.Set(true)

View file

@ -162,6 +162,7 @@ func TestLocalDBClientGetJSON(t *testing.T) {
A string A string
B int B int
} }
testStruct := test{ testStruct := test{
A: "test", A: "test",
B: 42, B: 42,

View file

@ -142,20 +142,18 @@ const supportedMessages: EventSubNotificationType[] = [
EventSubNotificationType.SubscriptionGifted, EventSubNotificationType.SubscriptionGifted,
]; ];
const eventSubKeyFunction = (ev: EventSubNotification) =>
`${ev.subscription.type}-${ev.subscription.created_at}-${JSON.stringify(
ev.event,
)}`;
function TwitchEvent({ data }: { data: EventSubNotification }) { function TwitchEvent({ data }: { data: EventSubNotification }) {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useAppSelector((state) => state.api.client); const client = useAppSelector((state) => state.api.client);
const replay = () => { const replay = () => {
void client.putJSON( void client.putJSON(`twitch/ev/eventsub-event/${data.subscription.type}`, {
`twitch/ev/eventsub-event/${data.subscription.type}`, ...data,
data, subscription: {
); ...data.subscription,
created_at: new Date().toISOString(),
},
});
}; };
let content: JSX.Element | string; let content: JSX.Element | string;
@ -392,7 +390,10 @@ function TwitchEventLog({ events }: { events: EventSubNotification[] }) {
Date.parse(a.subscription.created_at), Date.parse(a.subscription.created_at),
) )
.map((ev) => ( .map((ev) => (
<TwitchEvent key={eventSubKeyFunction(ev)} data={ev} /> <TwitchEvent
key={`${ev.subscription.id}-${ev.subscription.created_at}`}
data={ev}
/>
))} ))}
</EventListContainer> </EventListContainer>
</Scrollbar> </Scrollbar>
@ -440,29 +441,27 @@ function TwitchSection() {
const kv = useAppSelector((state) => state.api.client); const kv = useAppSelector((state) => state.api.client);
const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]); const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]);
const keyfn = (ev: EventSubNotification) => JSON.stringify(ev); const keyfn = (ev: EventSubNotification) =>
ev.subscription.id + ev.subscription.created_at;
const addTwitchEvents = (events: EventSubNotification[]) => { const setCleanTwitchEvents = (events: EventSubNotification[]) => {
setTwitchEvents((currentEvents) => { const eventKeys = events.map(keyfn);
const allEvents = currentEvents.concat(events);
const eventKeys = allEvents.map(keyfn);
// Clean up duplicates before setting to state // Clean up duplicates before setting to state
const updatedEvents = allEvents.filter( const uniqueEvents = events.filter(
(ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos, (ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos,
); );
setTwitchEvents(uniqueEvents);
return updatedEvents;
});
}; };
const loadRecentEvents = async () => { const loadRecentEvents = async () => {
const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/'); const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/');
const events = Object.values(keymap) const events = Object.values(keymap)
.map((value) => JSON.parse(value) as EventSubNotification[]) .map((value) => JSON.parse(value) as EventSubNotification[])
.flat(); .flat()
.sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
addTwitchEvents(events); setCleanTwitchEvents(events);
}; };
useEffect(() => { useEffect(() => {
@ -470,10 +469,7 @@ function TwitchSection() {
const onKeyChange = (value: string) => { const onKeyChange = (value: string) => {
const event = JSON.parse(value) as EventSubNotification; const event = JSON.parse(value) as EventSubNotification;
if (!supportedMessages.includes(event.subscription.type)) { void setCleanTwitchEvents([event, ...twitchEvents]);
return;
}
void addTwitchEvents([event]);
}; };
void kv.subscribePrefix('twitch/ev/eventsub-event/', onKeyChange); void kv.subscribePrefix('twitch/ev/eventsub-event/', onKeyChange);
@ -543,7 +539,7 @@ function ProblemList() {
}; };
void kv.subscribeKey('twitch/auth-keys', onKeyChange); void kv.subscribeKey('twitch/auth-keys', onKeyChange);
const url = await GetTwitchAuthURL('stream'); const url = await GetTwitchAuthURL();
BrowserOpenURL(url); BrowserOpenURL(url);
}; };

View file

@ -450,13 +450,15 @@ function TwitchEventsStep() {
const { t } = useTranslation(); const { t } = useTranslation();
const [userStatus, setUserStatus] = useState<helix.User | SyncError>(null); const [userStatus, setUserStatus] = useState<helix.User | SyncError>(null);
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig); const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const [botConfig, setBotConfig] = useModule(modules.twitchChatConfig);
const [uiConfig, setUiConfig] = useModule(modules.uiConfig); const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const [authKeys, setAuthKeys] = useState<TwitchCredentials>(null);
const kv = useSelector((state: RootState) => state.api.client); const kv = useSelector((state: RootState) => state.api.client);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const getUserInfo = async () => { const getUserInfo = async () => {
try { try {
const res = await GetTwitchLoggedUser('twitch/auth-keys'); const res = await GetTwitchLoggedUser();
setUserStatus(res); setUserStatus(res);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -465,7 +467,7 @@ function TwitchEventsStep() {
}; };
const startAuthFlow = async () => { const startAuthFlow = async () => {
const url = await GetTwitchAuthURL('stream'); const url = await GetTwitchAuthURL();
BrowserOpenURL(url); BrowserOpenURL(url);
}; };
@ -475,6 +477,16 @@ function TwitchEventsStep() {
await dispatch( await dispatch(
setTwitchConfig({ setTwitchConfig({
...twitchConfig, ...twitchConfig,
enable_bot: true,
}),
);
await dispatch(
setBotConfig({
...botConfig,
username: userStatus.login,
oauth: `oauth:${authKeys.access_token}`,
channel: userStatus.login,
chat_history: 5,
}), }),
); );
} }
@ -491,10 +503,17 @@ function TwitchEventsStep() {
// Get user info // Get user info
void getUserInfo(); void getUserInfo();
const onKeyChange = () => { const onKeyChange = (newValue: string) => {
setAuthKeys(JSON.parse(newValue) as TwitchCredentials);
void getUserInfo(); void getUserInfo();
}; };
void kv.getKey('twitch/auth-keys').then((auth) => {
if (auth) {
setAuthKeys(JSON.parse(auth) as TwitchCredentials);
}
});
void kv.subscribeKey('twitch/auth-keys', onKeyChange); void kv.subscribeKey('twitch/auth-keys', onKeyChange);
return () => { return () => {
void kv.unsubscribeKey('twitch/auth-keys', onKeyChange); void kv.unsubscribeKey('twitch/auth-keys', onKeyChange);

View file

@ -53,6 +53,54 @@ const Step = styled('li', {
}, },
}); });
function TwitchChatSettings() {
const [chatConfig, setChatConfig, loadStatus] = useModule(
modules.twitchChatConfig,
);
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const status = useStatus(loadStatus.save);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const disabled = status?.type === 'pending';
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
void dispatch(setChatConfig(chatConfig));
ev.preventDefault();
}}
>
<SectionHeader>
{t('pages.twitch-settings.bot-chat-header')}
</SectionHeader>
<Field size="fullWidth">
<Label htmlFor="bot-chat-history">
{t('pages.twitch-settings.bot-chat-cooldown-tip')}
</Label>
<InputBox
type="number"
id="bot-chat-history"
required={true}
disabled={disabled}
defaultValue={
chatConfig ? chatConfig.command_cooldown ?? 2 : undefined
}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchChatConfigChanged({
...chatConfig,
command_cooldown: parseInt(ev.target.value, 10),
}),
)
}
/>
</Field>
<SaveButton status={status} />
</form>
);
}
type TestResult = { open: boolean; error?: Error }; type TestResult = { open: boolean; error?: Error };
function TwitchAPISettings() { function TwitchAPISettings() {
@ -227,63 +275,25 @@ const TwitchPic = styled('img', {
}); });
const TwitchName = styled('p', { fontWeight: 'bold' }); const TwitchName = styled('p', { fontWeight: 'bold' });
async function startAuthFlow(target: string) { function TwitchEventSubSettings() {
const url = await GetTwitchAuthURL(target);
BrowserOpenURL(url);
}
function TwitchUserBlock({ authKey }: { authKey: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [user, setUser] = useState<helix.User | SyncError>(null); const [userStatus, setUserStatus] = useState<helix.User | SyncError>(null);
const kv = useAppSelector((state) => state.api.client); const kv = useAppSelector((state) => state.api.client);
const getUserInfo = async () => { const getUserInfo = async () => {
try { try {
const res = await GetTwitchLoggedUser(authKey); const res = await GetTwitchLoggedUser();
setUser(res); setUserStatus(res);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUser({ ok: false, error: (e as Error).message }); setUserStatus({ ok: false, error: (e as Error).message });
} }
}; };
useEffect(() => { const startAuthFlow = async () => {
// Get user info const url = await GetTwitchAuthURL();
void getUserInfo(); BrowserOpenURL(url);
const onKeyChange = () => {
void getUserInfo();
}; };
void kv.subscribeKey(authKey, onKeyChange);
return () => {
void kv.unsubscribeKey(authKey, onKeyChange);
};
}, []);
if (user !== null) {
if ('id' in user) {
return (
<TwitchUser>
<TextBlock>
{t('pages.twitch-settings.events.authenticated-as')}
</TextBlock>
<TwitchPic
src={user.profile_image_url}
alt={t('pages.twitch-settings.events.profile-picture')}
/>
<TwitchName>{user.display_name}</TwitchName>
</TwitchUser>
);
}
return <span>{t('pages.twitch-settings.events.err-no-user')}</span>;
}
return <i>{t('pages.twitch-settings.events.loading-data')}</i>;
}
function TwitchEventSubSettings() {
const { t } = useTranslation();
const kv = useAppSelector((state) => state.api.client);
const sendFakeEvent = async (event: keyof typeof eventsubTests) => { const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
const data = eventsubTests[event]; const data = eventsubTests[event];
@ -297,13 +307,47 @@ function TwitchEventSubSettings() {
}); });
}; };
useEffect(() => {
// Get user info
void getUserInfo();
const onKeyChange = () => {
void getUserInfo();
};
void kv.subscribeKey('twitch/auth-keys', onKeyChange);
return () => {
void kv.unsubscribeKey('twitch/auth-keys', onKeyChange);
};
}, []);
let userBlock = <i>{t('pages.twitch-settings.events.loading-data')}</i>;
if (userStatus !== null) {
if ('id' in userStatus) {
userBlock = (
<>
<TwitchUser>
<TextBlock>
{t('pages.twitch-settings.events.authenticated-as')}
</TextBlock>
<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 ( return (
<> <>
<TextBlock>{t('pages.twitch-settings.events.auth-message')}</TextBlock> <TextBlock>{t('pages.twitch-settings.events.auth-message')}</TextBlock>
<Button <Button
variation="primary" variation="primary"
onClick={() => { onClick={() => {
void startAuthFlow('stream'); void startAuthFlow();
}} }}
> >
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')} <ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
@ -311,7 +355,7 @@ function TwitchEventSubSettings() {
<SectionHeader> <SectionHeader>
{t('pages.twitch-settings.events.current-status')} {t('pages.twitch-settings.events.current-status')}
</SectionHeader> </SectionHeader>
<TwitchUserBlock authKey={'twitch/auth-keys'} /> {userBlock}
<SectionHeader> <SectionHeader>
{t('pages.twitch-settings.events.sim-events')} {t('pages.twitch-settings.events.sim-events')}
</SectionHeader> </SectionHeader>
@ -331,54 +375,6 @@ function TwitchEventSubSettings() {
); );
} }
function TwitchChatSettings() {
const [chatConfig, setChatConfig, loadStatus] = useModule(
modules.twitchChatConfig,
);
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const status = useStatus(loadStatus.save);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const disabled = status?.type === 'pending';
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
void dispatch(setChatConfig(chatConfig));
ev.preventDefault();
}}
>
<SectionHeader>
{t('pages.twitch-settings.bot-chat-header')}
</SectionHeader>
<Field size="fullWidth">
<Label htmlFor="bot-chat-history">
{t('pages.twitch-settings.bot-chat-cooldown-tip')}
</Label>
<InputBox
type="number"
id="bot-chat-history"
required={true}
disabled={disabled}
defaultValue={
chatConfig ? chatConfig.command_cooldown ?? 2 : undefined
}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchChatConfigChanged({
...chatConfig,
command_cooldown: parseInt(ev.target.value, 10),
}),
)
}
/>
</Field>
<SaveButton status={status} />
</form>
);
}
export default function TwitchSettingsPage(): React.ReactElement { export default function TwitchSettingsPage(): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig); const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);

View file

@ -19,9 +19,9 @@ export function GetLastLogs():Promise<Array<log.Entry>>;
export function GetProblems():Promise<Array<main.Problem>>; export function GetProblems():Promise<Array<main.Problem>>;
export function GetTwitchAuthURL(arg1:string):Promise<string>; export function GetTwitchAuthURL():Promise<string>;
export function GetTwitchLoggedUser(arg1:string):Promise<helix.User>; export function GetTwitchLoggedUser():Promise<helix.User>;
export function IsFatalError():Promise<boolean>; export function IsFatalError():Promise<boolean>;

View file

@ -30,12 +30,12 @@ export function GetProblems() {
return window['go']['main']['App']['GetProblems'](); return window['go']['main']['App']['GetProblems']();
} }
export function GetTwitchAuthURL(arg1) { export function GetTwitchAuthURL() {
return window['go']['main']['App']['GetTwitchAuthURL'](arg1); return window['go']['main']['App']['GetTwitchAuthURL']();
} }
export function GetTwitchLoggedUser(arg1) { export function GetTwitchLoggedUser() {
return window['go']['main']['App']['GetTwitchLoggedUser'](arg1); return window['go']['main']['App']['GetTwitchLoggedUser']();
} }
export function IsFatalError() { export function IsFatalError() {

View file

@ -7,11 +7,7 @@ import (
) )
func Error(err error) slog.Attr { func Error(err error) slog.Attr {
return ErrorSkip(err, 2) pc, filename, line, _ := runtime.Caller(1)
}
func ErrorSkip(err error, skip int) slog.Attr {
pc, filename, line, _ := runtime.Caller(skip)
return slog.Group("error", return slog.Group("error",
slog.String("message", err.Error()), slog.String("message", err.Error()),

View file

@ -125,9 +125,8 @@ func NewManager(ctx context.Context, db database.Database, twitchManager *twitch
loyalty.SetBanList(config.BanList) loyalty.SetBanList(config.BanList)
// Start twitch handler // Start twitch handler
twitchClient := twitchManager.Client() if twitchManager.Client() != nil {
if twitchClient != nil && twitchClient.Chat != nil { loyalty.twitchIntegration = setupTwitchIntegration(loyaltyContext, loyalty, twitchManager.Client().Chat)
loyalty.twitchIntegration = setupTwitchIntegration(loyaltyContext, loyalty, twitchClient.Chat)
} }
return loyalty, nil return loyalty, nil

View file

@ -25,16 +25,16 @@ type AuthResponse struct {
Time time.Time Time time.Time
} }
func GetUserClient(db database.Database, keyPath string, forceRefresh bool) (*helix.Client, error) { func GetUserClient(db database.Database, forceRefresh bool) (*helix.Client, error) {
var authResp AuthResponse var authResp AuthResponse
if err := db.GetJSON(keyPath, &authResp); err != nil { if err := db.GetJSON(AuthKey, &authResp); err != nil {
return nil, err return nil, err
} }
// Handle token expiration // Handle token expiration
if forceRefresh || time.Now().After(authResp.Time.Add(time.Duration(authResp.ExpiresIn)*time.Second)) { if forceRefresh || time.Now().After(authResp.Time.Add(time.Duration(authResp.ExpiresIn)*time.Second)) {
// Refresh tokens // Refresh tokens
api, err := GetHelixAPI(db, "") api, err := GetHelixAPI(db)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -49,31 +49,30 @@ func GetUserClient(db database.Database, keyPath string, forceRefresh bool) (*he
authResp.Time = time.Now().Add(time.Duration(refreshed.Data.ExpiresIn) * time.Second) authResp.Time = time.Now().Add(time.Duration(refreshed.Data.ExpiresIn) * time.Second)
// Save new token pair // Save new token pair
err = db.PutJSON(keyPath, authResp) err = db.PutJSON(AuthKey, authResp)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
return GetHelixAPI(db, authResp.AccessToken)
}
func GetHelixAPI(db database.Database, userToken string) (*helix.Client, error) {
config, err := GetConfig(db) config, err := GetConfig(db)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// If a user token is provided, create a user client
if userToken != "" {
return helix.NewClient(&helix.Options{ return helix.NewClient(&helix.Options{
ClientID: config.APIClientID, ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret, ClientSecret: config.APIClientSecret,
UserAccessToken: userToken, UserAccessToken: authResp.AccessToken,
}) })
}
func GetHelixAPI(db database.Database) (*helix.Client, error) {
config, err := GetConfig(db)
if err != nil {
return nil, err
} }
// If no user token is provided, create an app client
baseurl, err := baseURL(db) baseurl, err := baseURL(db)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -4,7 +4,6 @@ const (
ActivityKey = "twitch/chat/activity" ActivityKey = "twitch/chat/activity"
CustomCommandsKey = "twitch/chat/custom-commands" CustomCommandsKey = "twitch/chat/custom-commands"
WriteMessageRPC = "twitch/chat/@send-message" WriteMessageRPC = "twitch/chat/@send-message"
CustomAccountKey = "twitch/chat/chatter-account"
) )
type ResponseType string type ResponseType string

View file

@ -39,20 +39,11 @@ type Module struct {
} }
func Setup(ctx context.Context, db database.Database, api *helix.Client, user helix.User, logger *slog.Logger, templater template.Engine) *Module { func Setup(ctx context.Context, db database.Database, api *helix.Client, user helix.User, logger *slog.Logger, templater template.Engine) *Module {
customUserClient, customUserInfo, err := GetCustomUser(db)
if err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
logger.Error("Failed to get custom user, falling back to streamer account", log.Error(err))
}
customUserClient = api
customUserInfo = user
}
mod := &Module{ mod := &Module{
ctx: ctx, ctx: ctx,
db: db, db: db,
api: customUserClient, api: api,
user: customUserInfo, user: user,
logger: logger, logger: logger,
templater: templater, templater: templater,
lastMessage: sync.NewRWSync(time.Now()), lastMessage: sync.NewRWSync(time.Now()),
@ -245,7 +236,7 @@ func (mod *Module) UnregisterCommand(name string) {
func (mod *Module) GetChatters() (users []string) { func (mod *Module) GetChatters() (users []string) {
cursor := "" cursor := ""
for { for {
userClient, err := twitch.GetUserClient(mod.db, twitch.AuthKey, false) userClient, err := twitch.GetUserClient(mod.db, false)
if err != nil { if err != nil {
slog.Error("Could not get user api client for list of chatters", log.Error(err)) slog.Error("Could not get user api client for list of chatters", log.Error(err))
return return
@ -269,19 +260,3 @@ func (mod *Module) GetChatters() (users []string) {
} }
} }
} }
func GetCustomUser(db database.Database) (*helix.Client, helix.User, error) {
userClient, err := twitch.GetUserClient(db, CustomAccountKey, true)
if err != nil {
return nil, helix.User{}, err
}
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
return nil, helix.User{}, err
}
if len(users.Data.Users) < 1 {
return nil, helix.User{}, errors.New("no users found")
}
return userClient, users.Data.Users[0], nil
}

View file

@ -5,8 +5,6 @@ import (
"net/http" "net/http"
"time" "time"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch" "git.sr.ht/~ashkeel/strimertul/twitch"
"github.com/nicklaw5/helix/v2" "github.com/nicklaw5/helix/v2"
@ -17,7 +15,7 @@ func (c *Client) GetLoggedUser() (helix.User, error) {
return c.User, nil return c.User, nil
} }
client, err := twitch.GetUserClient(c.DB, twitch.AuthKey, false) client, err := twitch.GetUserClient(c.DB, false)
if err != nil { if err != nil {
return helix.User{}, fmt.Errorf("failed getting API client for user: %w", err) return helix.User{}, fmt.Errorf("failed getting API client for user: %w", err)
} }
@ -50,15 +48,7 @@ func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return return
} }
// Check scope to see which credentials are we getting (stream/chat) err = c.DB.PutJSON(twitch.AuthKey, twitch.AuthResponse{
state := req.URL.Query().Get("state")
key := twitch.AuthKey
switch state {
case "chat":
key = chat.CustomAccountKey
}
err = c.DB.PutJSON(key, twitch.AuthResponse{
AccessToken: userTokenResponse.Data.AccessToken, AccessToken: userTokenResponse.Data.AccessToken,
RefreshToken: userTokenResponse.Data.RefreshToken, RefreshToken: userTokenResponse.Data.RefreshToken,
ExpiresIn: userTokenResponse.Data.ExpiresIn, ExpiresIn: userTokenResponse.Data.ExpiresIn,
@ -69,7 +59,6 @@ func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
http.Error(w, "error saving auth data for user: "+err.Error(), http.StatusInternalServerError) http.Error(w, "error saving auth data for user: "+err.Error(), http.StatusInternalServerError)
return return
} }
w.Header().Add("Content-Type", "text/html") 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>`) _, _ = fmt.Fprintf(w, `<html><body><h2>All done, you can close me now!</h2><script>window.close();</script></body></html>`)
} }

View file

@ -69,6 +69,7 @@ func NewManager(ctx context.Context, db database.Database, server *webserver.Web
} }
// New client works, replace old // New client works, replace old
updatedClient.Merge(manager.client)
manager.client = updatedClient manager.client = updatedClient
logger.Info("Reloaded/updated Twitch integration") logger.Info("Reloaded/updated Twitch integration")
@ -97,11 +98,25 @@ type Client struct {
eventSub *eventsub.Client eventSub *eventsub.Client
server *webserver.WebServer server *webserver.WebServer
ctx context.Context ctx context.Context
cancel context.CancelFunc
restart chan bool restart chan bool
streamOnline *sync.RWSync[bool] streamOnline *sync.RWSync[bool]
} }
func (c *Client) Merge(old *Client) {
// Copy bot instance and some params
c.streamOnline.Set(old.streamOnline.Get())
c.ensureRoute()
}
// Hacky function to deal with sync issues when restarting client
func (c *Client) ensureRoute() {
if c.Config.Get().Enabled {
c.server.RegisterRoute(twitch.CallbackRoute, c)
}
}
func newClient(ctx context.Context, config twitch.Config, db database.Database, server *webserver.WebServer) (*Client, error) { func newClient(ctx context.Context, config twitch.Config, db database.Database, server *webserver.WebServer) (*Client, error) {
// Create Twitch client // Create Twitch client
client := &Client{ client := &Client{
@ -119,14 +134,35 @@ func newClient(ctx context.Context, config twitch.Config, db database.Database,
} }
var err error var err error
client.API, err = twitch.GetHelixAPI(db, "") client.API, err = twitch.GetHelixAPI(db)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err) return nil, fmt.Errorf("failed to create twitch client: %w", err)
} }
server.RegisterRoute(twitch.CallbackRoute, client) server.RegisterRoute(twitch.CallbackRoute, client)
client.initializeEventSubUserFeatures() if userClient, err := twitch.GetUserClient(db, true); err == nil {
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
client.Logger.Error("Failed looking up user", log.Error(err))
} else if len(users.Data.Users) < 1 {
client.Logger.Error("No users found, please authenticate in Twitch configuration -> Events")
} else {
client.Logger.Info("Twitch user identified", slog.String("user", users.Data.Users[0].ID))
client.User = users.Data.Users[0]
client.eventSub, err = eventsub.Setup(ctx, userClient, client.User, db, client.Logger)
if err != nil {
client.Logger.Error("Failed to setup EventSub", log.Error(err))
}
tpl := client.GetTemplateEngine()
client.Chat = chat.Setup(ctx, db, userClient, client.User, client.Logger, tpl)
client.Alerts = alerts.Setup(ctx, db, client.Logger, tpl)
client.Timers = timers.Setup(ctx, db, client.Logger)
}
} else {
client.Logger.Warn("Twitch user not identified, this will break most features")
}
go client.runStatusPoll() go client.runStatusPoll()
@ -138,36 +174,6 @@ func newClient(ctx context.Context, config twitch.Config, db database.Database,
return client, nil return client, nil
} }
func (c *Client) initializeEventSubUserFeatures() {
userClient, err := twitch.GetUserClient(c.DB, twitch.AuthKey, true)
if err != nil {
c.Logger.Warn("Twitch user not identified, this will break most features")
return
}
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
c.Logger.Error("Failed looking up user", log.Error(err))
return
}
if len(users.Data.Users) < 1 {
c.Logger.Error("No users found, please authenticate in Twitch configuration -> Events")
return
}
c.Logger.Info("Twitch user identified", slog.String("user", users.Data.Users[0].ID))
c.User = users.Data.Users[0]
c.eventSub, err = eventsub.Setup(c.ctx, userClient, c.User, c.DB, c.Logger)
if err != nil {
c.Logger.Error("Failed to setup EventSub", log.Error(err))
return
}
tpl := c.GetTemplateEngine()
c.Chat = chat.Setup(c.ctx, c.DB, userClient, c.User, c.Logger, tpl)
c.Alerts = alerts.Setup(c.ctx, c.DB, c.Logger, tpl)
c.Timers = timers.Setup(c.ctx, c.DB, c.Logger)
}
func (c *Client) runStatusPoll() { func (c *Client) runStatusPoll() {
c.Logger.Info("Started polling for stream status") c.Logger.Info("Started polling for stream status")
for { for {

View file

@ -14,7 +14,7 @@ func TestNewClient(t *testing.T) {
client, _ := database.CreateInMemoryLocalClient(t) client, _ := database.CreateInMemoryLocalClient(t)
defer database.CleanupLocalClient(client) defer database.CleanupLocalClient(client)
server, err := webserver.NewServer(context.Background(), client, webserver.DefaultServerFactory) server, err := webserver.NewServer(client, webserver.DefaultServerFactory)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View file

@ -48,14 +48,12 @@ func CheckScopes(db database.Database) (bool, error) {
return slices.Equal(scopes, authResp.Scope), nil return slices.Equal(scopes, authResp.Scope), nil
} }
func GetAuthorizationURL(api *helix.Client, state string) string { func GetAuthorizationURL(api *helix.Client) string {
if api == nil { if api == nil {
return "twitch-not-configured" return "twitch-not-configured"
} }
return api.GetAuthorizationURL(&helix.AuthorizationURLParams{ return api.GetAuthorizationURL(&helix.AuthorizationURLParams{
ResponseType: "code", ResponseType: "code",
Scopes: scopes, Scopes: scopes,
State: state,
ForceVerify: true,
}) })
} }

View file

@ -1,7 +1,6 @@
package webserver package webserver
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -16,7 +15,7 @@ func TestNewServer(t *testing.T) {
client, _ := database.CreateInMemoryLocalClient(t) client, _ := database.CreateInMemoryLocalClient(t)
defer database.CleanupLocalClient(client) defer database.CleanupLocalClient(client)
_, err := NewServer(context.Background(), client, DefaultServerFactory) _, err := NewServer(client, DefaultServerFactory)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -27,7 +26,7 @@ func TestNewServerWithTestFactory(t *testing.T) {
defer database.CleanupLocalClient(client) defer database.CleanupLocalClient(client)
testServer := NewTestServer() testServer := NewTestServer()
_, err := NewServer(context.Background(), client, testServer.Factory()) _, err := NewServer(client, testServer.Factory())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -39,7 +38,7 @@ func TestListen(t *testing.T) {
// Create a test server // Create a test server
testServer := NewTestServer() testServer := NewTestServer()
server, err := NewServer(context.Background(), client, testServer.Factory()) server, err := NewServer(client, testServer.Factory())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -96,7 +95,7 @@ func TestCustomRoute(t *testing.T) {
defer database.CleanupLocalClient(client) defer database.CleanupLocalClient(client)
// Create test server // Create test server
server, err := NewServer(context.Background(), client, nil) server, err := NewServer(client, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }