mirror of https://git.sr.ht/~ashkeel/strimertul
428 lines
12 KiB
TypeScript
428 lines
12 KiB
TypeScript
/* eslint-disable no-param-reassign */
|
|
import {
|
|
AsyncThunk,
|
|
CaseReducer,
|
|
createAction,
|
|
createAsyncThunk,
|
|
createSlice,
|
|
Dispatch,
|
|
PayloadAction,
|
|
UnknownAction,
|
|
} from '@reduxjs/toolkit';
|
|
import KilovoltWS, { KilovoltMessage } from '@strimertul/kilovolt-client';
|
|
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
|
|
import { AuthenticateKVClient, IsServerReady } from '@wailsapp/go/main/App';
|
|
import { delay } from '~/lib/time';
|
|
import {
|
|
APIState,
|
|
ConnectionStatus,
|
|
HTTPConfig,
|
|
LoyaltyPointsEntry,
|
|
LoyaltyRedeem,
|
|
LoyaltyStorage,
|
|
TwitchChatConfig,
|
|
TwitchConfig,
|
|
TwitchChatCustomCommands,
|
|
TwitchChatTimersConfig,
|
|
TwitchChatAlertsConfig,
|
|
LoyaltyConfig,
|
|
LoyaltyReward,
|
|
LoyaltyGoal,
|
|
UISettings,
|
|
} from './types';
|
|
import { ThunkConfig } from '..';
|
|
|
|
type ThunkAPIState = { api: APIState };
|
|
|
|
interface AppThunkAPI {
|
|
dispatch: Dispatch;
|
|
getState: () => ThunkAPIState;
|
|
}
|
|
|
|
function makeGetSetThunks<T>(key: string) {
|
|
const getter = createAsyncThunk<T, void, { state: ThunkAPIState }>(
|
|
`api/get/${key}`,
|
|
async (_, { getState }) => {
|
|
const { api } = getState();
|
|
return api.client.getJSON<T>(key);
|
|
},
|
|
);
|
|
const setter = createAsyncThunk<KilovoltMessage, T, { state: ThunkAPIState }>(
|
|
`api/set/${key}`,
|
|
async (data: T, { getState, dispatch }: AppThunkAPI) => {
|
|
const { api } = getState();
|
|
const result = await api.client.putJSON(key, data);
|
|
if ('ok' in result) {
|
|
if (result.ok) {
|
|
// Re-load value from KV
|
|
// Need to do type fuckery to avoid cyclic redundancy
|
|
// (unless there's a better way that I'm missing)
|
|
void dispatch(getter() as unknown as UnknownAction);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
);
|
|
return { getter, setter };
|
|
}
|
|
|
|
function makeModule<T>(
|
|
key: string,
|
|
selector: (state: APIState) => T,
|
|
stateSetter: CaseReducer<APIState>,
|
|
) {
|
|
return {
|
|
...makeGetSetThunks<T>(key),
|
|
key,
|
|
selector,
|
|
stateSetter,
|
|
asyncSetter: createAction<T>(`asyncSetter/${key}`),
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
let setupClientReconnect: AsyncThunk<void, KilovoltWS, {}>;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
let kvErrorReceived: AsyncThunk<void, kvError, {}>;
|
|
|
|
// Storage
|
|
const loyaltyPointsPrefix = 'loyalty/points/';
|
|
const loyaltyRewardsKey = 'loyalty/rewards';
|
|
|
|
// RPCs
|
|
const loyaltyCreateRedeemKey = 'loyalty/@create-redeem';
|
|
const loyaltyRemoveRedeemKey = 'loyalty/@remove-redeem';
|
|
|
|
export const createWSClient = createAsyncThunk(
|
|
'api/createClient',
|
|
async (options: { address: string; password?: string }, { dispatch }) => {
|
|
// Wait for server to be ready
|
|
let ready = false;
|
|
while (!ready) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
ready = await IsServerReady();
|
|
if (ready) {
|
|
break;
|
|
}
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await delay(1000);
|
|
}
|
|
// Connect to websocket
|
|
const client = new KilovoltWS(options.address, {
|
|
password: options.password,
|
|
});
|
|
client.on('error', (err: CustomEvent<kvError>) => {
|
|
void dispatch(kvErrorReceived(err.detail));
|
|
});
|
|
await client.connect();
|
|
await dispatch(setupClientReconnect(client));
|
|
return client;
|
|
},
|
|
);
|
|
|
|
export const getUserPoints = createAsyncThunk<
|
|
LoyaltyStorage,
|
|
void,
|
|
ThunkConfig
|
|
>('api/getUserPoints', async (_, { getState }) => {
|
|
const { api } = getState();
|
|
const keys = await api.client.getKeysByPrefix(loyaltyPointsPrefix);
|
|
const userpoints: LoyaltyStorage = {};
|
|
Object.entries(keys).forEach(([k, v]) => {
|
|
userpoints[k.substring(loyaltyPointsPrefix.length)] = JSON.parse(
|
|
v,
|
|
) as LoyaltyPointsEntry;
|
|
});
|
|
return userpoints;
|
|
});
|
|
|
|
export const setUserPoints = createAsyncThunk<
|
|
KilovoltMessage,
|
|
{ user: string; points: number; relative: boolean },
|
|
ThunkConfig
|
|
>('api/setUserPoints', async ({ user, points, relative }, { getState }) => {
|
|
const { api } = getState();
|
|
const entry: LoyaltyPointsEntry = { points };
|
|
if (relative) {
|
|
entry.points += api.loyalty.users[user]?.points ?? 0;
|
|
}
|
|
return api.client.putJSON(loyaltyPointsPrefix + user, entry);
|
|
});
|
|
|
|
export const modules = {
|
|
httpConfig: makeModule(
|
|
'http/config',
|
|
(state) => state.moduleConfigs?.httpConfig,
|
|
(state, { payload }) => {
|
|
state.moduleConfigs.httpConfig = payload as HTTPConfig;
|
|
},
|
|
),
|
|
twitchConfig: makeModule(
|
|
'twitch/config',
|
|
(state) => state.moduleConfigs?.twitchConfig,
|
|
(state, { payload }) => {
|
|
state.moduleConfigs.twitchConfig = payload as TwitchConfig;
|
|
},
|
|
),
|
|
twitchChatConfig: makeModule(
|
|
'twitch/chat/config',
|
|
(state) => state.moduleConfigs?.twitchChatConfig,
|
|
(state, { payload }) => {
|
|
state.moduleConfigs.twitchChatConfig = payload as TwitchChatConfig;
|
|
},
|
|
),
|
|
twitchChatCommands: makeModule(
|
|
'twitch/chat/custom-commands',
|
|
(state) => state.twitchChat?.commands,
|
|
(state, { payload }) => {
|
|
state.twitchChat.commands = payload as TwitchChatCustomCommands;
|
|
},
|
|
),
|
|
twitchChatTimers: makeModule(
|
|
'twitch/timers/config',
|
|
(state) => state.twitchChat?.timers,
|
|
(state, { payload }) => {
|
|
state.twitchChat.timers = payload as TwitchChatTimersConfig;
|
|
},
|
|
),
|
|
twitchChatAlerts: makeModule(
|
|
'twitch/alerts/config',
|
|
(state) => state.twitchChat?.alerts,
|
|
(state, { payload }) => {
|
|
state.twitchChat.alerts = payload as TwitchChatAlertsConfig;
|
|
},
|
|
),
|
|
loyaltyConfig: makeModule(
|
|
'loyalty/config',
|
|
(state) => state.moduleConfigs?.loyaltyConfig,
|
|
(state, { payload }) => {
|
|
state.moduleConfigs.loyaltyConfig = payload as LoyaltyConfig;
|
|
},
|
|
),
|
|
loyaltyRewards: makeModule(
|
|
loyaltyRewardsKey,
|
|
(state) => state.loyalty.rewards,
|
|
(state, { payload }) => {
|
|
state.loyalty.rewards = payload as LoyaltyReward[];
|
|
},
|
|
),
|
|
loyaltyGoals: makeModule(
|
|
'loyalty/goals',
|
|
(state) => state.loyalty.goals,
|
|
(state, { payload }) => {
|
|
state.loyalty.goals = payload as LoyaltyGoal[];
|
|
},
|
|
),
|
|
loyaltyRedeemQueue: makeModule(
|
|
'loyalty/redeem-queue',
|
|
(state) => state.loyalty.redeemQueue,
|
|
(state, { payload }) => {
|
|
state.loyalty.redeemQueue = payload as LoyaltyRedeem[];
|
|
},
|
|
),
|
|
uiConfig: makeModule(
|
|
'ui/settings',
|
|
(state) => state.uiConfig,
|
|
(state, { payload }) => {
|
|
state.uiConfig = payload as UISettings;
|
|
},
|
|
),
|
|
};
|
|
|
|
export const createRedeem = createAsyncThunk<
|
|
KilovoltMessage,
|
|
LoyaltyRedeem,
|
|
ThunkConfig
|
|
>('api/createRedeem', async (redeem: LoyaltyRedeem, { getState }) => {
|
|
const { api } = getState();
|
|
return api.client.putJSON(loyaltyCreateRedeemKey, redeem);
|
|
});
|
|
|
|
export const removeRedeem = createAsyncThunk<
|
|
KilovoltMessage,
|
|
LoyaltyRedeem,
|
|
ThunkConfig
|
|
>('api/removeRedeem', async (redeem: LoyaltyRedeem, { getState }) => {
|
|
const { api } = getState();
|
|
return api.client.putJSON(loyaltyRemoveRedeemKey, redeem);
|
|
});
|
|
|
|
const moduleChangeReducers = Object.fromEntries(
|
|
Object.entries(modules).map(([key, mod]) => [
|
|
`${key}Changed`,
|
|
mod.stateSetter,
|
|
]),
|
|
) as Record<
|
|
`${keyof typeof modules}Changed`,
|
|
(state: APIState, action: PayloadAction<unknown>) => never
|
|
>;
|
|
|
|
const initialState: APIState = {
|
|
client: null,
|
|
connectionStatus: ConnectionStatus.NotConnected,
|
|
kvError: null,
|
|
initialLoadComplete: false,
|
|
loyalty: {
|
|
users: null,
|
|
rewards: null,
|
|
goals: null,
|
|
redeemQueue: null,
|
|
},
|
|
twitchChat: {
|
|
commands: null,
|
|
timers: null,
|
|
alerts: null,
|
|
},
|
|
moduleConfigs: {
|
|
httpConfig: null,
|
|
twitchConfig: null,
|
|
twitchChatConfig: null,
|
|
loyaltyConfig: null,
|
|
},
|
|
uiConfig: null,
|
|
requestStatus: {},
|
|
};
|
|
|
|
const apiReducer = createSlice({
|
|
name: 'api',
|
|
initialState,
|
|
reducers: {
|
|
...moduleChangeReducers,
|
|
initialLoadCompleted(state) {
|
|
state.initialLoadComplete = true;
|
|
},
|
|
connectionStatusChanged(
|
|
state,
|
|
{ payload }: PayloadAction<ConnectionStatus>,
|
|
) {
|
|
state.connectionStatus = payload;
|
|
},
|
|
kvErrorReceived(state, { payload }: PayloadAction<kvError>) {
|
|
state.kvError = payload;
|
|
},
|
|
loyaltyUserPointsChanged(
|
|
state,
|
|
{
|
|
payload: { user, entry },
|
|
}: PayloadAction<{ user: string; entry: LoyaltyPointsEntry }>,
|
|
) {
|
|
state.loyalty.users[user] = entry;
|
|
},
|
|
requestKeysRemoved(state, { payload }: PayloadAction<string[]>) {
|
|
payload.forEach((key) => {
|
|
delete state.requestStatus[key];
|
|
});
|
|
},
|
|
},
|
|
extraReducers: (builder) => {
|
|
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
|
|
state.client = payload;
|
|
state.connectionStatus = ConnectionStatus.Connected;
|
|
});
|
|
builder.addCase(getUserPoints.fulfilled, (state, { payload }) => {
|
|
state.loyalty.users = payload;
|
|
});
|
|
Object.values(modules).forEach((mod) => {
|
|
builder.addCase(mod.getter.pending, (state) => {
|
|
state.requestStatus[`load-${mod.key}`] = {
|
|
type: 'pending',
|
|
updated: new Date(),
|
|
};
|
|
});
|
|
builder.addCase(mod.getter.fulfilled, (state, action) => {
|
|
state.requestStatus[`load-${mod.key}`] = {
|
|
type: 'success',
|
|
updated: new Date(),
|
|
};
|
|
mod.stateSetter(state, action);
|
|
});
|
|
builder.addCase(mod.getter.rejected, (state, { error }) => {
|
|
state.requestStatus[`load-${mod.key}`] = {
|
|
type: 'error',
|
|
error: error.message,
|
|
updated: new Date(),
|
|
};
|
|
});
|
|
builder.addCase(mod.setter.pending, (state) => {
|
|
state.requestStatus[`save-${mod.key}`] = {
|
|
type: 'pending',
|
|
updated: new Date(),
|
|
};
|
|
});
|
|
builder.addCase(mod.setter.fulfilled, (state) => {
|
|
state.requestStatus[`save-${mod.key}`] = {
|
|
type: 'success',
|
|
updated: new Date(),
|
|
};
|
|
});
|
|
builder.addCase(mod.setter.rejected, (state, { error }) => {
|
|
state.requestStatus[`save-${mod.key}`] = {
|
|
type: 'error',
|
|
error: error.message,
|
|
updated: new Date(),
|
|
};
|
|
});
|
|
builder.addCase(mod.asyncSetter, mod.stateSetter);
|
|
});
|
|
},
|
|
});
|
|
|
|
setupClientReconnect = createAsyncThunk(
|
|
'api/setupClientReconnect',
|
|
(client: KilovoltWS, { dispatch }) => {
|
|
client.on('close', () => {
|
|
setTimeout(() => {
|
|
console.info('Attempting reconnection');
|
|
client.reconnect();
|
|
}, 5000);
|
|
dispatch(
|
|
apiReducer.actions.connectionStatusChanged(
|
|
ConnectionStatus.NotConnected,
|
|
),
|
|
);
|
|
});
|
|
client.on('open', () => {
|
|
dispatch(
|
|
apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
kvErrorReceived = createAsyncThunk(
|
|
'api/kvErrorReceived',
|
|
(error: kvError, { dispatch }) => {
|
|
switch (error.error) {
|
|
case 'authentication required':
|
|
case 'authentication failed':
|
|
dispatch(
|
|
apiReducer.actions.connectionStatusChanged(
|
|
ConnectionStatus.AuthenticationNeeded,
|
|
),
|
|
);
|
|
break;
|
|
default:
|
|
// Unsupported error
|
|
dispatch(apiReducer.actions.kvErrorReceived(error));
|
|
}
|
|
},
|
|
);
|
|
|
|
export const useAuthBypass = createAsyncThunk<void, void, ThunkConfig>(
|
|
'api/authBypass',
|
|
async (_: void, { getState, dispatch }) => {
|
|
const { api } = getState();
|
|
const response = await api.client.send({ command: '_uid' });
|
|
if ('ok' in response && response.ok && 'data' in response) {
|
|
const uid = response.data as string;
|
|
await AuthenticateKVClient(uid.toString());
|
|
dispatch(
|
|
apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
export default apiReducer;
|