Skip to content

Commit a31958a

Browse files
Added chat bootstrapping
1 parent b6593d3 commit a31958a

File tree

14 files changed

+158
-58
lines changed

14 files changed

+158
-58
lines changed

apps/ui/src/router.tsx

+17
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { SignIn } from './pages/authentication/SignIn';
1616
import { SignOut } from './pages/authentication/SignOut';
1717
import { SignOutRedirect } from './pages/authentication/SignOutRedirect';
1818
import { useSnapshot } from './services/api/snapshot';
19+
import { useAPI } from './services/query/hooks';
1920

2021
const auth: RouteObject = {
2122
path: 'auth',
@@ -357,13 +358,29 @@ const Thumbnail: FC<{ url: string; selected: boolean; onClick: () => void; keep:
357358
);
358359
};
359360

361+
const Status: FC = () => {
362+
const api = useAPI();
363+
const response = useSuspenseQuery({
364+
queryKey: ['status'],
365+
queryFn: () => {
366+
return api.chat.status.query();
367+
}
368+
});
369+
370+
return <div>{JSON.stringify(response.data)}</div>;
371+
};
372+
360373
export const router = createBrowserRouter([
361374
{
362375
element: <Authenticated />,
363376
children: [
364377
{
365378
element: <Main />,
366379
children: [
380+
{
381+
path: '/status',
382+
element: <Status />
383+
},
367384
{
368385
path: '/snapshots/:id',
369386
element: <Home />

docker-compose.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ services:
2727
# https://docs.docker.com/compose/compose-file/compose-file-v3/#network_mode
2828
# network_mode: "host"
2929
volumes:
30-
- dragonflydata:/data
30+
- dragonflydata2:/data
3131

3232
volumes:
33-
dragonflydata:
33+
dragonflydata2:
3434
pgfdata:

infrastructure/pulumi/Pulumi.prod.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ config:
88
secure: AAABAG0Qc+H+3ZiC/BAz9AsJRBt8X4J1IJ+CwlpQMoqGGxehsbBxq6vAPwaY+FuEk1y005zygnwBljatENg=
99
crittercapture:twitch-username: pollinatorcam
1010
crittercapture:ui-url: https://www.crittercapture.club
11+
crittercapture:api-url: https://api.crittercapture.club

infrastructure/pulumi/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export = async () => {
184184
...website,
185185
env: {
186186
variables: {
187-
apiBaseUrl: api.defaultUrl,
187+
apiBaseUrl: config.get('api-url') ?? api.defaultUrl,
188188
appInsightsConnectionString: appInsights.connectionString
189189
},
190190
flags: {}

services/api/src/api/chat.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { status } from '../services/chat/index.js';
2+
import { moderatorProcedure, router } from '../trpc/trpc.js';
3+
4+
export default router({
5+
status: moderatorProcedure.query(async ({ ctx }) => {
6+
return status;
7+
})
8+
});

services/api/src/api/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { router } from '../trpc/trpc.js';
2+
import chat from './chat.js';
23
import feed from './feed.js';
34
import me from './me.js';
45
import snapshot from './snapshot.js';
56

67
export default router({
78
me,
89
feed,
9-
snapshot
10+
snapshot,
11+
chat
1012
});

services/api/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { start } from './services/chat/index.js';
99
import { createContext } from './trpc/context.js';
1010

1111
import authRouter from './services/auth/router.js';
12+
import adminRouter from './services/chat/auth/router.js';
1213
// Export type router type signature,
1314
// NOT the router itself.
1415
export type AppRouter = typeof router;
@@ -23,7 +24,7 @@ import { createEnvironment, withEnvironment } from './utils/env/env.js';
2324
const server = fastify(options);
2425
await server.register(cors);
2526
await server.register(authRouter);
26-
27+
await server.register(adminRouter);
2728
await server.register(fastifyTRPCPlugin, {
2829
trpcOptions: {
2930
router,

services/api/src/services/chat/auth/auth.ts services/api/src/services/auth/auth.ts

+1-27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { exchangeCode } from '@twurple/auth';
22
import z from 'zod';
3-
import { useEnvironment } from '../../../utils/env/env.js';
3+
import { useEnvironment } from '../../utils/env/env.js';
44

55
const scopes = ['chat:read', 'chat:edit', 'user:read:chat', 'user:write:chat'];
66
export const createSignInRequest = (path: string, state: string) => {
@@ -26,32 +26,6 @@ export const exchangeCodeForToken = async (code: string) => {
2626
return token;
2727
};
2828

29-
const TwitchAccessToken = z.object({
30-
accessToken: z.string(),
31-
refreshToken: z.string().nullable(),
32-
expiresIn: z.number().nullable(),
33-
scope: z.array(z.string()),
34-
obtainmentTimestamp: z.number()
35-
});
36-
37-
export const saveToken = async (token: z.infer<typeof TwitchAccessToken>) => {
38-
const env = useEnvironment();
39-
await env.redis.set('twitch:token', JSON.stringify(token));
40-
};
41-
42-
export const restoreToken = async () => {
43-
const env = useEnvironment();
44-
const token = await env.redis.get('twitch:token');
45-
if (!token) return;
46-
47-
const parsed = TwitchAccessToken.safeParse(JSON.parse(token));
48-
if (!parsed.success) {
49-
console.warn(`Invalid token in redis: ${token}`);
50-
return;
51-
}
52-
return parsed.data;
53-
};
54-
5529
const TwitchValidationResponse = z.object({
5630
user_id: z.string()
5731
});

services/api/src/services/auth/router.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { TimeSpan } from 'oslo';
33
import { createJWT } from 'oslo/jwt';
44
import { z } from 'zod';
55
import { useEnvironment } from '../../utils/env/env.js';
6-
import { createSignInRequest, exchangeCodeForToken, getUserInformation, validateToken } from '../chat/auth/auth.js';
6+
import { createSignInRequest, exchangeCodeForToken, getUserInformation, validateToken } from './auth.js';
77

88
const TwitchRedirectResponse = z.object({
99
code: z.string(),
+21-9
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,39 @@
11
import { FastifyInstance } from 'fastify';
22
import { z } from 'zod';
3-
import { createSignInRequest, exchangeCodeForToken, restoreToken, saveToken } from './auth.js';
3+
import { useEnvironment } from '../../../utils/env/env.js';
4+
import { createSignInRequest, exchangeCodeForToken, getUserInformation, validateToken } from '../../auth/auth.js';
5+
import { start, status } from '../index.js';
6+
import { saveToken } from './token.js';
47

58
const TwitchRedirectResponse = z.object({
69
code: z.string(),
710
scope: z.string()
811
});
912

1013
export default async function register(router: FastifyInstance) {
11-
router.get('/auth/signin', async (request, reply) => {
12-
const url = createSignInRequest('/auth/redirect', crypto.randomUUID());
14+
router.get('/admin/signin', async (request, reply) => {
15+
const url = createSignInRequest('/admin/redirect', crypto.randomUUID());
1316
return reply.redirect(url);
1417
});
1518

16-
router.get('/auth/redirect', async (request, reply) => {
19+
router.get('/admin/redirect', async (request, reply) => {
20+
const env = useEnvironment();
1721
const query = TwitchRedirectResponse.parse(request.query);
1822
const token = await exchangeCodeForToken(query.code);
23+
if (!(await validateToken(token.accessToken))) {
24+
throw new Error('Invalid token');
25+
}
26+
27+
const user = await getUserInformation(token.accessToken);
28+
if (user.login !== env.variables.TWITCH_USERNAME) {
29+
throw new Error('Invalid user');
30+
}
31+
1932
await saveToken(token);
20-
return reply.send(`Thank you for signing in!`);
21-
});
2233

23-
router.get('/auth/status', async (request, reply) => {
24-
const token = await restoreToken();
25-
return reply.send(token);
34+
if (status.status === 'offline:not_authenticated') {
35+
await start();
36+
}
37+
return reply.send(`Thank you for signing in!`);
2638
});
2739
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { z } from 'zod';
2+
import { useEnvironment } from '../../../utils/env/env.js';
3+
4+
const TwitchAccessToken = z.object({
5+
accessToken: z.string(),
6+
refreshToken: z.string().nullable(),
7+
expiresIn: z.number().nullable(),
8+
scope: z.array(z.string()),
9+
obtainmentTimestamp: z.number()
10+
});
11+
12+
export const saveToken = async (token: z.infer<typeof TwitchAccessToken>) => {
13+
const env = useEnvironment();
14+
await env.redis.set('twitch:token', JSON.stringify(token));
15+
};
16+
17+
export const restoreToken = async () => {
18+
const env = useEnvironment();
19+
const token = await env.redis.get('twitch:token');
20+
if (!token) return;
21+
22+
const parsed = TwitchAccessToken.safeParse(JSON.parse(token));
23+
if (!parsed.success) {
24+
console.warn(`Invalid token in redis: ${token}`);
25+
return;
26+
}
27+
return parsed.data;
28+
};

services/api/src/services/chat/index.ts

+38-10
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,41 @@ import { RefreshingAuthProvider } from '@twurple/auth';
22
import { ChatClient } from '@twurple/chat';
33
import { useEnvironment } from '../../utils/env/env.js';
44
import { createSnapshotRequest } from '../snapshot/index.js';
5-
import { restoreToken, saveToken } from './auth/auth.js';
5+
import { restoreToken, saveToken } from './auth/token.js';
6+
interface Status {
7+
status: 'pending' | 'offline:not_authenticated' | 'offline' | 'online';
8+
channels: string[];
9+
commands: string[];
10+
}
11+
12+
const handler: Record<string, (chat: ChatClient, channel: string, user: string, message: string) => Promise<void>> = {
13+
capture: async (chat: ChatClient, channel: string, user: string, message: string) => {
14+
const env = useEnvironment();
15+
const [_, duration, rewind] = message.split(' ');
16+
if (isNaN(Number(duration)) || isNaN(Number(rewind))) {
17+
chat.say(channel, `@${user} Invalid duration or rewind. Please use !capture <duration> <rewind>`);
18+
return;
19+
}
20+
const request = await createSnapshotRequest('twitch', user, Number(duration), Number(rewind));
21+
chat.say(channel, `@${user} Critter captured! ${env.variables.UI_URL}/snapshots/${request.id}`);
22+
}
23+
};
24+
25+
export const status: Status = {
26+
status: 'pending',
27+
channels: ['strangecyan', 'alveusgg'],
28+
commands: Object.keys(handler)
29+
};
630

731
export const start = async () => {
832
const env = useEnvironment();
933

1034
const token = await restoreToken();
11-
if (!token) throw new Error('No token found, cannot start chat. Please sign in.');
35+
if (!token) {
36+
status.status = 'offline:not_authenticated';
37+
return;
38+
}
39+
1240
const auth = new RefreshingAuthProvider({
1341
clientId: env.variables.TWITCH_CLIENT_ID,
1442
clientSecret: env.variables.TWITCH_CLIENT_SECRET
@@ -17,20 +45,20 @@ export const start = async () => {
1745
auth.onRefresh((_, token) => saveToken(token));
1846
await auth.addUserForToken(token, ['chat']);
1947

20-
const chat = new ChatClient({ authProvider: auth, channels: ['strangecyan', 'alveusgg'] });
48+
const chat = new ChatClient({ authProvider: auth, channels: status.channels });
49+
chat.onConnect(() => (status.status = 'online'));
50+
51+
chat.onDisconnect(() => (status.status = 'offline'));
52+
2153
chat.connect();
2254

2355
chat.onMessage(async (channel, user, message) => {
2456
if (!message.startsWith('!')) return;
2557

26-
if (message.startsWith('!polcapture')) {
27-
const [_, duration, rewind] = message.split(' ');
28-
if (isNaN(Number(duration)) || isNaN(Number(rewind))) {
29-
chat.say(channel, `@${user} Invalid duration or rewind. Please use !polcapture <duration> <rewind>`);
30-
return;
58+
for (const command of status.commands) {
59+
if (message.startsWith(`!${command}`)) {
60+
await handler[command](chat, channel, user, message);
3161
}
32-
const request = await createSnapshotRequest('twitch', user, Number(duration), Number(rewind));
33-
chat.say(channel, `@${user} Critter captured! ${env.variables.UI_URL}/snapshots/${request.id}`);
3462
}
3563
});
3664
};

services/api/src/trpc/trpc.ts

+27-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { initTRPC } from '@trpc/server';
22
import { eq } from 'drizzle-orm';
33
import { validateJWT } from 'oslo/jwt';
4-
import { feeds } from '../db/schema/index.js';
5-
import { withUser } from '../utils/env/env.js';
4+
import { feeds, roles } from '../db/schema/index.js';
5+
import { useUser, withUser } from '../utils/env/env.js';
66
import { createContext } from './context.js';
77

88
const t = initTRPC.context<typeof createContext>().create();
@@ -25,6 +25,31 @@ export const procedure = t.procedure.use(async ({ ctx, next }) => {
2525
return withUser({ id: decoded.subject }, next);
2626
});
2727

28+
export const moderatorProcedure = procedure.use(async ({ ctx, next }) => {
29+
const user = useUser();
30+
const [role] = await ctx.db.select().from(roles).where(eq(roles.username, user.id));
31+
if (role?.role !== 'mod' && role?.role !== 'admin') {
32+
throw new Error('Unauthorized');
33+
}
34+
return next();
35+
});
36+
37+
export const adminProcedure = procedure.use(async ({ ctx, next }) => {
38+
const user = useUser();
39+
const [role] = await ctx.db.select().from(roles).where(eq(roles.username, user.id));
40+
if (role?.role !== 'admin') {
41+
throw new Error('Unauthorized');
42+
}
43+
return next();
44+
});
45+
46+
export const editorProcedure = procedure.use(async ({ ctx, next }) => {
47+
const user = useUser();
48+
const [role] = await ctx.db.select().from(roles).where(eq(roles.username, user.id));
49+
if (!role?.role) throw new Error('Unauthorized');
50+
return next();
51+
});
52+
2853
export const integrationProcedure = t.procedure.use(async ({ ctx, next }) => {
2954
const headers = ctx.req.headers;
3055
const authorization = headers.authorization;

services/api/src/utils/env/config.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ import { initialise } from '../../db/db.js';
55
export const config = z.object({
66
TWITCH_CLIENT_ID: z.string(),
77
TWITCH_CLIENT_SECRET: z.string(),
8+
TWITCH_USERNAME: z.string(),
9+
10+
NODE_ENV: z.enum(['development', 'production']).default('development'),
11+
HOST: z.string(),
12+
PORT: z.coerce.number(),
13+
814
REDIS_HOST: z.string(),
915
REDIS_PORT: z.string(),
1016
REDIS_PASSWORD: z.string().optional(),
1117
REDIS_SSL: z.coerce.boolean().default(false),
12-
HOST: z.string(),
13-
PORT: z.coerce.number(),
18+
1419
POSTGRES_HOST: z.string(),
1520
POSTGRES_USER: z.string(),
1621
POSTGRES_PASSWORD: z.string(),
@@ -19,8 +24,7 @@ export const config = z.object({
1924

2025
UI_URL: z.string(),
2126

22-
JWT_SECRET: z.string().transform(value => Buffer.from(value, 'hex')),
23-
NODE_ENV: z.enum(['development', 'production']).default('development')
27+
JWT_SECRET: z.string().transform(value => Buffer.from(value, 'hex'))
2428
});
2529

2630
export const services = async (variables: z.infer<typeof config>) => {

0 commit comments

Comments
 (0)