mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
Compare commits
63 Commits
feat/proto
...
feat/js-sd
| Author | SHA1 | Date | |
|---|---|---|---|
| 7456e80473 | |||
| a0cabbfa63 | |||
| 2f8ac7d343 | |||
| 0157eff9f3 | |||
| ebcb062b6a | |||
| fdc8e0f33c | |||
| eeb44dfae7 | |||
|
|
0e9f0a54dd | ||
|
|
5d81d32276 | ||
|
|
fe21d19250 | ||
| eac736b9fb | |||
| 381f4fc523 | |||
| 7d350cfc04 | |||
| 2dfbab5d0e | |||
| 4eef997d63 | |||
| 7574b94933 | |||
| 6c26ca9d2f | |||
| a1727b9a3d | |||
| f486c3b28e | |||
| 8e86be97d1 | |||
| 099b321b79 | |||
| 6fdadbec28 | |||
| 92cde437af | |||
| 28cbe4e8ed | |||
| 09d099d0ee | |||
| 5c99fee95d | |||
| df845b5601 | |||
| d4a6516157 | |||
| 17bbba7df3 | |||
|
|
1e27c7e77a | ||
|
|
80595d6299 | ||
|
|
aa9d0c1ca5 | ||
|
|
45894fc900 | ||
| ddbdf3caf9 | |||
| 80a8e670e1 | |||
| 3e5824093e | |||
| 75d6e648f9 | |||
|
|
1fadaa3600 | ||
|
|
7262b0e5c2 | ||
|
|
70832c7de8 | ||
|
|
61972da255 | ||
| 221aff0050 | |||
| 5b6addac9a | |||
| 5add3b0e5d | |||
| b623de5bdd | |||
| cc15a06ffb | |||
| c0f3e9d52e | |||
| a22dcf0746 | |||
|
|
b4d3cd5bb8 | ||
|
|
d5c02889de | ||
| c0657cc1ce | |||
| d97add9659 | |||
| 8f07dbadf3 | |||
| 21ab8a5e4f | |||
| 689c410828 | |||
| 593baa6505 | |||
| 786a2afb6c | |||
| 75f25eb8fe | |||
| 0e500037c4 | |||
| b49318f9e6 | |||
| 927d7d1bda | |||
| d1f5cc7a6d | |||
| 0afc54f0bf |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -46,7 +46,6 @@ jobs:
|
||||
|
||||
mkdir -p apps/web/src/lib/instrumentation/
|
||||
|
||||
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
|
||||
./slack-import-emojis-bin default
|
||||
|
||||
cp emojis.json apps/web/
|
||||
@@ -63,6 +62,7 @@ jobs:
|
||||
secrets: |
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
chat:
|
||||
name: Push chat module to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -28,6 +28,7 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -47,4 +48,6 @@ packages/db/generated/client
|
||||
slack-import-emojis/target
|
||||
**/*/emojis.json
|
||||
|
||||
.idea
|
||||
.idea
|
||||
|
||||
/apps/docs/src/content/docs/typedoc-sdk
|
||||
343
AGENTS.md
Normal file
343
AGENTS.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Agent Guidelines for HackClub.tv
|
||||
|
||||
This document provides essential information for AI coding agents working on the HackClub.tv codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
HackClub.tv is a live streaming platform built with Next.js 16, Prisma, and Turbo monorepo architecture.
|
||||
|
||||
- **Monorepo**: Turborepo with pnpm workspaces
|
||||
- **Apps**: web (Next.js), chat (Hono), docs
|
||||
- **Packages**: db (Prisma), auth (Lucia), hono-ws, sdk
|
||||
- **Package Manager**: pnpm 10.6.5
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
### Root Level Commands
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
pnpm build # Build all apps and packages (uses Turbo)
|
||||
pnpm dev # Start all apps in dev mode (uses Turbo)
|
||||
pnpm lint # Lint all apps (uses Turbo)
|
||||
```
|
||||
|
||||
### Database Commands
|
||||
|
||||
```bash
|
||||
pnpm db:migrate # Run Prisma migrations in dev
|
||||
pnpm prisma # Run any Prisma command in db package
|
||||
```
|
||||
|
||||
### App-Specific Commands
|
||||
|
||||
```bash
|
||||
# Web app (Next.js)
|
||||
pnpm --filter=@hctv/web dev # Start Next.js dev server
|
||||
pnpm --filter=@hctv/web build # Build Next.js app
|
||||
pnpm --filter=@hctv/web lint # Lint web app
|
||||
pnpm --filter=@hctv/web check-types # Type check (tsc --noEmit)
|
||||
pnpm --filter=@hctv/web ui:add # Add shadcn components
|
||||
|
||||
# Chat app (Hono)
|
||||
pnpm --filter=@hctv/chat dev # Start chat server with watch
|
||||
pnpm --filter=@hctv/chat build # Build chat server
|
||||
|
||||
# DB package (Prisma)
|
||||
pnpm --filter=@hctv/db db:generate # Generate Prisma client
|
||||
pnpm --filter=@hctv/db db:migrate # Run migrations
|
||||
pnpm --filter=@hctv/db build # Generate client and build
|
||||
```
|
||||
|
||||
### Running Single Tests
|
||||
|
||||
This project does not currently have a test suite configured. When adding tests:
|
||||
|
||||
- Use Vitest or Jest for unit tests
|
||||
- Use Playwright for E2E tests (recommended for Next.js)
|
||||
- Follow the pattern: `pnpm test -- <test-file-path>`
|
||||
|
||||
### Docker Commands
|
||||
|
||||
```bash
|
||||
pnpm docker:web # Build web app Docker image
|
||||
pnpm docker:chat # Build chat app Docker image
|
||||
pnpm r:rtmp # Restart RTMP server
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Formatting
|
||||
|
||||
- **Formatter**: Prettier (`.prettierrc.json`)
|
||||
- **Indentation**: 2 spaces (no tabs)
|
||||
- **Line Width**: 100 characters
|
||||
- **Quotes**: Single quotes
|
||||
- **Semicolons**: Required
|
||||
- **Trailing Commas**: ES5 style
|
||||
- **Linter**: ESLint with Next.js rules (`next/core-web-vitals`)
|
||||
|
||||
### Import Organization
|
||||
|
||||
Imports should be ordered as follows (no blank lines between groups):
|
||||
|
||||
1. React/Next.js core imports
|
||||
2. Third-party libraries
|
||||
3. Internal components (`@/components`)
|
||||
4. Internal utilities/libs (`@/lib`)
|
||||
5. Package imports (`@hctv/*`)
|
||||
6. Type imports (use `import type` keyword)
|
||||
7. Relative imports
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import type { User, Channel } from '@hctv/db';
|
||||
import { helper } from './utils';
|
||||
```
|
||||
|
||||
### TypeScript Usage
|
||||
|
||||
- **Strict mode**: Enabled
|
||||
- **Type over interface**: Use `type` for unions/intersections, `interface` for object shapes
|
||||
- **No implicit any**: Always type your variables
|
||||
- **Type imports**: Use `import type` for type-only imports
|
||||
- **Prisma types**: Import from `@hctv/db` and use type composition
|
||||
|
||||
```typescript
|
||||
// Interfaces for props and object shapes
|
||||
interface ChatMessage {
|
||||
user?: User;
|
||||
message: string;
|
||||
type: 'message' | 'systemMsg';
|
||||
}
|
||||
|
||||
// Type aliases for complex types
|
||||
type FormFieldConfig = {
|
||||
name: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
// Prisma type composition
|
||||
type StreamWithChannel = StreamInfo & { channel: Channel };
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
**Files:**
|
||||
|
||||
- React components: `PascalCase.tsx` (e.g., `ChatPanel.tsx`)
|
||||
- Utilities/helpers: `camelCase.ts` (e.g., `validate.ts`)
|
||||
- Next.js pages: `page.tsx`, `route.ts`, `layout.tsx`
|
||||
- Client components: `page.client.tsx`
|
||||
|
||||
**Variables & Functions:**
|
||||
|
||||
- Components: `PascalCase`
|
||||
- Functions: `camelCase`
|
||||
- Constants: `SCREAMING_SNAKE_CASE`
|
||||
- Booleans: Use `is`, `has`, `should` prefixes
|
||||
- Refs: `Ref` suffix (e.g., `socketRef`)
|
||||
|
||||
```typescript
|
||||
const MESSAGE_HISTORY_SIZE = 15;
|
||||
const isFollowing = await checkFollowing();
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**API Routes:**
|
||||
|
||||
- Return `Response` objects with appropriate status codes
|
||||
- Use descriptive error messages
|
||||
- Status codes: 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found)
|
||||
|
||||
```typescript
|
||||
if (!user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
```
|
||||
|
||||
**Server Actions:**
|
||||
|
||||
- Return objects with `success` boolean and `error` or `data` fields
|
||||
- Use `zodVerify` helper for validation
|
||||
|
||||
```typescript
|
||||
const zod = await zodVerify(schema, formData);
|
||||
if (!zod.success) {
|
||||
return { success: false, error: zod.error };
|
||||
}
|
||||
return { success: true, data: result };
|
||||
```
|
||||
|
||||
**Client-side:**
|
||||
|
||||
- Use try-catch for async operations
|
||||
- Use toast notifications (sonner) for user feedback
|
||||
- Log errors with `console.error`
|
||||
|
||||
### Async Patterns
|
||||
|
||||
- **Prefer**: async/await over Promise chains
|
||||
- **Parallel operations**: Use `Promise.all()`
|
||||
- **No .then() chaining**: Except in utility functions like fetchers
|
||||
|
||||
```typescript
|
||||
// Standard async/await
|
||||
const data = await prisma.model.findMany();
|
||||
|
||||
// Parallel operations
|
||||
const [channelA, channelB] = await Promise.all([
|
||||
prisma.channel.findUnique({ where: { id: 'a' } }),
|
||||
prisma.channel.findUnique({ where: { id: 'b' } }),
|
||||
]);
|
||||
```
|
||||
|
||||
## React Component Patterns
|
||||
|
||||
### Component Structure
|
||||
|
||||
1. Directive (`'use client'` or `'use server'`)
|
||||
2. Imports
|
||||
3. Component function
|
||||
4. Helper functions (if needed)
|
||||
5. Type/interface definitions (at bottom)
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ChatPanel(props: Props) {
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
return <div>{/* JSX */}</div>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Server vs Client Components
|
||||
|
||||
- **Server components**: Default (no directive), use for data fetching
|
||||
- **Client components**: Add `'use client'`, use for interactivity
|
||||
- Fetch data in server components, pass to client components as props
|
||||
|
||||
## Database Patterns (Prisma)
|
||||
|
||||
### Imports
|
||||
|
||||
```typescript
|
||||
import { prisma } from '@hctv/db';
|
||||
import type { User, Channel, StreamInfo } from '@hctv/db';
|
||||
```
|
||||
|
||||
### Common Queries
|
||||
|
||||
```typescript
|
||||
// FindUnique with relations
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { name: channelName },
|
||||
include: { owner: true, streamInfo: true },
|
||||
});
|
||||
|
||||
// FindMany with dynamic filters
|
||||
const where: Prisma.StreamInfoWhereInput = {};
|
||||
if (isLive) where.isLive = true;
|
||||
const streams = await prisma.streamInfo.findMany({ where });
|
||||
|
||||
// Create with relations
|
||||
await prisma.channel.create({
|
||||
data: {
|
||||
name: channelName,
|
||||
ownerId: user.id,
|
||||
personalFor: { connect: { id: user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
// Update
|
||||
await prisma.streamInfo.update({
|
||||
where: { username },
|
||||
data: { title: newTitle },
|
||||
});
|
||||
|
||||
// Upsert
|
||||
await prisma.streamKey.upsert({
|
||||
create: { key: newKey, channelId },
|
||||
update: { key: newKey },
|
||||
where: { channelId },
|
||||
});
|
||||
```
|
||||
|
||||
### Redis Usage
|
||||
|
||||
```typescript
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
|
||||
const redis = getRedisConnection();
|
||||
await redis.set('key', 'value');
|
||||
await redis.get('key');
|
||||
await redis.setex('key', 30, 'value'); // with expiry
|
||||
```
|
||||
|
||||
## API Route Patterns
|
||||
|
||||
### Next.js App Router
|
||||
|
||||
```typescript
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const param = searchParams.get('param');
|
||||
|
||||
const data = await prisma.model.findMany();
|
||||
return Response.json(data);
|
||||
}
|
||||
```
|
||||
|
||||
### Server Actions
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
export async function createChannel(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const zod = await zodVerify(createChannelSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
// ... processing
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Turbo caching**: Build outputs are cached. Use `--force` to bypass cache
|
||||
- **Environment variables**: Use `NEXT_PUBLIC_` prefix for client-side vars
|
||||
- **Styling**: Tailwind CSS with shadcn/ui components, use `cn()` for conditional classes
|
||||
- **Data fetching**: SWR for client-side, direct Prisma for server components
|
||||
- **Validation**: Zod schemas for form and API validation
|
||||
- **Cache invalidation**: Use `revalidatePath()` after mutations
|
||||
@@ -1,7 +1,7 @@
|
||||
# hackclub.tv
|
||||
|
||||
This is the source code for [hackclub.tv (hackclub.tv)](https://hackclub.tv), a livestreaming website for hackclubbers.
|
||||
This is the source code for [hackclub.tv](https://hackclub.tv), a livestreaming website for hackclubbers.
|
||||
|
||||
Development has been ongoing for a few months, and the site is now live! There are some half-baked features, but I'm all ears for feedback.
|
||||
The development setup guide can be read at <https://docs.hackclub.tv/guides/dev/>
|
||||
|
||||
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
|
||||
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the Hack Club Slack for discussion and updates!
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
FROM node:lts-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
RUN yarn global add turbo@^2
|
||||
RUN pnpm add -g turbo@^2
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune @hctv/chat --docker
|
||||
@@ -16,10 +19,10 @@ WORKDIR /app
|
||||
|
||||
# First install the dependencies
|
||||
COPY --from=builder /app/out/json/ .
|
||||
RUN yarn install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --concurrency=1
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) pnpm turbo run build --concurrency=1
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"build": "tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/auth": "*",
|
||||
"@hctv/db": "*",
|
||||
"@hctv/hono-ws": "*",
|
||||
"@hctv/auth": "workspace:*",
|
||||
"@hctv/db": "workspace:*",
|
||||
"@hctv/hono-ws": "workspace:*",
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@hono/node-ws": "^1.1.0",
|
||||
"@leeoniya/ufuzzy": "^1.0.18",
|
||||
|
||||
@@ -33,8 +33,9 @@ app.get(
|
||||
const token = getCookie(c, 'auth_session');
|
||||
const grant = c.req.query('grant');
|
||||
const authHeader = c.req.header('Authorization');
|
||||
const botAuth = c.req.query('botAuth');
|
||||
|
||||
if (!token && (!grant || grant === 'null') && !authHeader) {
|
||||
if (!token && (!grant || grant === 'null') && !authHeader && !botAuth) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
@@ -42,11 +43,22 @@ app.get(
|
||||
let chatUser: ChatUser | null = null;
|
||||
let personalChannel: any = null;
|
||||
|
||||
let apiKey: string | null = null;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const apiKey = authHeader.substring(7);
|
||||
const extractedKey = authHeader.substring(7);
|
||||
if (extractedKey.startsWith('hctvb_')) {
|
||||
apiKey = extractedKey;
|
||||
}
|
||||
} else if (botAuth && typeof botAuth === 'string' && botAuth.trim().length > 0) {
|
||||
if (botAuth.startsWith('hctvb_')) {
|
||||
apiKey = botAuth;
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
const botAccount = await prisma.botApiKey.findUnique({
|
||||
where: { key: apiKey },
|
||||
include: { botAccount: true }
|
||||
include: { botAccount: true },
|
||||
});
|
||||
|
||||
if (botAccount) {
|
||||
@@ -55,12 +67,12 @@ app.get(
|
||||
username: botAccount.botAccount.slug,
|
||||
pfpUrl: botAccount.botAccount.pfpUrl,
|
||||
displayName: botAccount.botAccount.displayName,
|
||||
isBot: true
|
||||
isBot: true,
|
||||
};
|
||||
|
||||
personalChannel = {
|
||||
id: botAccount.botAccount.id,
|
||||
name: botAccount.botAccount.slug
|
||||
name: botAccount.botAccount.slug,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -74,16 +86,19 @@ app.get(
|
||||
id: session.user.id,
|
||||
username: userChannel.name,
|
||||
pfpUrl: session.user.pfpUrl,
|
||||
isBot: false
|
||||
isBot: false,
|
||||
};
|
||||
personalChannel = userChannel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dbGrant = await prisma.channel.findFirst({
|
||||
where: { obsChatGrantToken: grant }
|
||||
});
|
||||
const dbGrant =
|
||||
grant && grant !== 'null'
|
||||
? await prisma.channel.findFirst({
|
||||
where: { obsChatGrantToken: grant },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!chatUser && !dbGrant) {
|
||||
ws.close();
|
||||
@@ -96,11 +111,17 @@ app.get(
|
||||
return;
|
||||
}
|
||||
|
||||
if (await prisma.channel.count({ where: { name: username } }) === 0) {
|
||||
// channel doesn't exist
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.targetUsername = username;
|
||||
ws.chatUser = chatUser;
|
||||
ws.personalChannel = personalChannel;
|
||||
ws.viewerId = randomString(10);
|
||||
|
||||
|
||||
if (ws.raw) {
|
||||
ws.raw.targetUsername = username;
|
||||
ws.raw.chatUser = chatUser;
|
||||
@@ -111,10 +132,12 @@ app.get(
|
||||
const messages = await redis.zrange(channelKey, 0, MESSAGE_HISTORY_SIZE - 1);
|
||||
|
||||
if (messages.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'history',
|
||||
messages: messages.map((msg) => JSON.parse(msg)),
|
||||
}));
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'history',
|
||||
messages: messages.map((msg) => JSON.parse(msg)),
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
async onClose(evt, ws) {
|
||||
@@ -136,115 +159,121 @@ app.get(
|
||||
await redis.del(`viewer:${ws.targetUsername}:${ws.viewerId}`);
|
||||
},
|
||||
async onMessage(evt, ws) {
|
||||
const msg = JSON.parse(evt.data.toString());
|
||||
|
||||
if (msg.type === 'ping') {
|
||||
await redis.setex(`viewer:${ws.targetUsername}:${ws.viewerId}`, 30, '1');
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(evt.data.toString());
|
||||
|
||||
if (msg.type === 'message') {
|
||||
if (!ws.chatUser || !ws.personalChannel) return;
|
||||
|
||||
const message = (msg.message as string).trim();
|
||||
const msgObj = {
|
||||
user: {
|
||||
id: ws.chatUser.id,
|
||||
username: ws.chatUser.username,
|
||||
pfpUrl: ws.chatUser.pfpUrl,
|
||||
displayName: ws.chatUser.displayName,
|
||||
isBot: ws.chatUser.isBot || false
|
||||
},
|
||||
message,
|
||||
};
|
||||
|
||||
const redisObj = {
|
||||
user: msgObj.user,
|
||||
message: msgObj.message,
|
||||
type: 'message',
|
||||
};
|
||||
|
||||
const redisStr = JSON.stringify(redisObj);
|
||||
const msgStr = JSON.stringify(msgObj);
|
||||
if (msg.type === 'ping') {
|
||||
await redis.setex(`viewer:${ws.targetUsername}:${ws.viewerId}`, 30, '1');
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const channelKey = `chat:history:${ws.targetUsername}`;
|
||||
await redis.zadd(channelKey, Date.now(), redisStr);
|
||||
await redis.zremrangebyrank(channelKey, 0, -MESSAGE_HISTORY_SIZE - 1);
|
||||
await redis.expire(channelKey, MESSAGE_TTL);
|
||||
if (msg.type === 'message') {
|
||||
if (!ws.chatUser || !ws.personalChannel) return;
|
||||
|
||||
ws.wss.clients.forEach((c) => {
|
||||
const client = c as ModifiedWebSocket;
|
||||
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
|
||||
c.send(msgStr);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (msg.type === 'emojiMsg') {
|
||||
const emojis = msg.emojis as string[];
|
||||
const emojiMap: Record<string, string> = {};
|
||||
const message = (msg.message as string).trim();
|
||||
const msgObj = {
|
||||
user: {
|
||||
id: ws.chatUser.id,
|
||||
username: ws.chatUser.username,
|
||||
pfpUrl: ws.chatUser.pfpUrl,
|
||||
displayName: ws.chatUser.displayName,
|
||||
isBot: ws.chatUser.isBot || false,
|
||||
},
|
||||
message,
|
||||
msgId: `${crypto.randomUUID()}`
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
emojis.map(async (emoji) => {
|
||||
let url = await redis.hget('emojis', emoji);
|
||||
|
||||
if (!url) {
|
||||
url = await redis.hget(`emojis:${emoji}`, 'url');
|
||||
const redisObj = {
|
||||
user: msgObj.user,
|
||||
message: msgObj.message,
|
||||
type: 'message',
|
||||
msgId: `${crypto.randomUUID()}`,
|
||||
};
|
||||
|
||||
const redisStr = JSON.stringify(redisObj);
|
||||
const msgStr = JSON.stringify(msgObj);
|
||||
|
||||
const channelKey = `chat:history:${ws.targetUsername}`;
|
||||
await redis.zadd(channelKey, Date.now(), redisStr);
|
||||
await redis.zremrangebyrank(channelKey, 0, -MESSAGE_HISTORY_SIZE - 1);
|
||||
await redis.expire(channelKey, MESSAGE_TTL);
|
||||
|
||||
ws.wss.clients.forEach((c) => {
|
||||
const client = c as ModifiedWebSocket;
|
||||
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
|
||||
c.send(msgStr);
|
||||
}
|
||||
if (!url) {
|
||||
url = await redis.hget(`emoji:${emoji}`, 'url');
|
||||
}
|
||||
|
||||
emojiMap[emoji] = url ?? '';
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
if (msg.type === 'emojiMsg') {
|
||||
const emojis = msg.emojis as string[];
|
||||
const emojiMap: Record<string, string> = {};
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiMsgResponse',
|
||||
emojis: emojiMap,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (msg.type === 'emojiSearch') {
|
||||
console.log('emoji search request:', msg);
|
||||
const searchTerm = msg.searchTerm as string;
|
||||
await Promise.all(
|
||||
emojis.map(async (emoji) => {
|
||||
let url = await redis.hget('emojis', emoji);
|
||||
|
||||
const emojis = await redis.hgetall('emojis');
|
||||
const emojiKeys = Object.keys(emojis);
|
||||
const idxs = uf.filter(emojiKeys, searchTerm);
|
||||
console.log(`Emoji search for "${searchTerm}" found ${idxs?.length || 0} results.`);
|
||||
|
||||
if (idxs && idxs.length > 0) {
|
||||
const results: string[] = [];
|
||||
|
||||
if (idxs.length <= 150) {
|
||||
const info = uf.info(idxs, emojiKeys, searchTerm);
|
||||
const order = uf.sort(info, emojiKeys, searchTerm);
|
||||
for (let i = 0; i < order.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[order[i]]]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < idxs.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: results,
|
||||
if (!url) {
|
||||
url = await redis.hget(`emojis:${emoji}`, 'url');
|
||||
}
|
||||
if (!url) {
|
||||
url = await redis.hget(`emoji:${emoji}`, 'url');
|
||||
}
|
||||
|
||||
emojiMap[emoji] = url ?? '';
|
||||
})
|
||||
);
|
||||
console.log(`Sending emoji search results: ${results.join(', ')}`);
|
||||
} else {
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: [],
|
||||
type: 'emojiMsgResponse',
|
||||
emojis: emojiMap,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (msg.type === 'emojiSearch') {
|
||||
console.log('emoji search request:', msg);
|
||||
const searchTerm = msg.searchTerm as string;
|
||||
|
||||
const emojis = await redis.hgetall('emojis');
|
||||
const emojiKeys = Object.keys(emojis);
|
||||
const idxs = uf.filter(emojiKeys, searchTerm);
|
||||
console.log(`Emoji search for "${searchTerm}" found ${idxs?.length || 0} results.`);
|
||||
|
||||
if (idxs && idxs.length > 0) {
|
||||
const results: string[] = [];
|
||||
|
||||
if (idxs.length <= 150) {
|
||||
const info = uf.info(idxs, emojiKeys, searchTerm);
|
||||
const order = uf.sort(info, emojiKeys, searchTerm);
|
||||
for (let i = 0; i < order.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[order[i]]]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < idxs.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: results,
|
||||
})
|
||||
);
|
||||
console.log(`Sending emoji search results: ${results.join(', ')}`);
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error processing message:', e);
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -267,4 +296,4 @@ interface ChatUser {
|
||||
pfpUrl: string;
|
||||
displayName?: string;
|
||||
isBot: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,44 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import mermaid from 'astro-mermaid';
|
||||
import catppuccin from "@catppuccin/starlight";
|
||||
import catppuccin from '@catppuccin/starlight';
|
||||
import starlightTypeDoc, { typeDocSidebarGroup } from 'starlight-typedoc';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
integrations: [
|
||||
mermaid({
|
||||
theme: 'base',
|
||||
autoTheme: true
|
||||
autoTheme: true,
|
||||
}),
|
||||
starlight({
|
||||
title: 'hctv docs',
|
||||
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/SrIzan10/hctv' }],
|
||||
starlight({
|
||||
title: 'hctv docs',
|
||||
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/SrIzan10/hctv' }],
|
||||
plugins: [
|
||||
catppuccin({
|
||||
dark: { flavor: "mocha", accent: "blue" },
|
||||
light: { flavor: "latte", accent: "blue" }
|
||||
dark: { flavor: 'mocha', accent: 'blue' },
|
||||
light: { flavor: 'latte', accent: 'blue' },
|
||||
}),
|
||||
]
|
||||
}),
|
||||
],
|
||||
starlightTypeDoc({
|
||||
entryPoints: ['../../packages/sdk/src/index.ts'],
|
||||
tsconfig: '../../packages/sdk/tsconfig.json',
|
||||
output: 'typedoc-sdk',
|
||||
sidebar: {
|
||||
label: 'SDK Reference',
|
||||
},
|
||||
}),
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
label: 'API',
|
||||
autogenerate: { directory: 'api' },
|
||||
},
|
||||
{
|
||||
label: 'Guides',
|
||||
autogenerate: { directory: 'guides' },
|
||||
},
|
||||
typeDocSidebarGroup,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"astro": "^5.6.1",
|
||||
"astro-mermaid": "^1.0.4",
|
||||
"mermaid": "^11.10.1",
|
||||
"sharp": "^0.34.2"
|
||||
"sharp": "^0.34.2",
|
||||
"starlight-typedoc": "^0.21.5",
|
||||
"typedoc": "^0.28.16",
|
||||
"typedoc-plugin-markdown": "^4.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,24 +11,31 @@ The chat system is powered by a websocket server. Please read the entire page be
|
||||
|
||||
The websocket server is located at `wss://hackclub.tv/api/chat/ws/:username`, where `:username` is the channel you want to connect to.
|
||||
|
||||
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
|
||||
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
|
||||
|
||||
<Aside type="tip">
|
||||
Bot accounts are now supported. You can choose to connect as a bot by providing a bot account's API key on the Authentication header: `Bearer hctvb_xxxxxxx`
|
||||
Bot accounts are now supported. You can choose to connect as a bot by providing a bot account's API key in one of two ways:
|
||||
- Using the `Authorization` header: `Bearer hctvb_xxxxxxx`
|
||||
- Using the `?botAuth=hctvb_xxxxxxx` query parameter
|
||||
|
||||
**Security Note:** When using the `?botAuth=` query parameter, be aware that query parameters may be logged in server logs, and/or proxy logs. Use the `Authorization` header method whenever possible. The query parameter method should only be used when connecting from an environment where headers cannot be set.
|
||||
|
||||
It is highly advised to use a bot account for any automated task, and to implement anything pointed out in this page.
|
||||
</Aside>
|
||||
|
||||
Once connected, you must implement a subroutine in your code to send ping messages every 5 seconds. This is because of Cloudflare limitations.
|
||||
Once connected, you must implement a subroutine in your code to send ping messages every about 5 seconds. This is because of Cloudflare limitations.
|
||||
|
||||
Messages are sent and received in JSON format. The following message types are supported:
|
||||
- `message`: a chat message.
|
||||
- sent by client:
|
||||
|
||||
- `message`: a chat message.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"content": "Hello, world!"
|
||||
"message": "Hello, world!"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
@@ -36,24 +43,24 @@ Messages are sent and received in JSON format. The following message types are s
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
},
|
||||
"message": "Hello, world!",
|
||||
"message": "Hello, world!"
|
||||
}
|
||||
```
|
||||
- `ping`: a ping message to keep the connection alive.
|
||||
- sent by client:
|
||||
- `ping`: a ping message to keep the connection alive.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
"type": "pong"
|
||||
}
|
||||
```
|
||||
- `history`: a message containing the chat history. This is sent upon connection.
|
||||
- received by client:
|
||||
- `history`: a message containing the chat history. This is sent upon connection.
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "history",
|
||||
@@ -71,9 +78,11 @@ Messages are sent and received in JSON format. The following message types are s
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Emoji handling
|
||||
|
||||
*diagram source: devin deepwiki*
|
||||
_diagram source: devin deepwiki_
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Emoji Processing Pipeline"
|
||||
@@ -111,6 +120,7 @@ The server then checks Redis for the emoji URL and returns it.
|
||||
When a user wants to look up an emoji (by typing `:(partial name)`), the server uses uFuzzy to find matching emojis in the Redis `emojis` hash key and returns the results.
|
||||
|
||||
Here's what gets sent on the websocket:
|
||||
|
||||
- `emojiMsg`: Looks up emojis
|
||||
- sent by client:
|
||||
```json
|
||||
|
||||
32
apps/docs/src/content/docs/guides/dev.mdx
Normal file
32
apps/docs/src/content/docs/guides/dev.mdx
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Development Setup
|
||||
description: Instructions to set up a local development environment for hackclub.tv
|
||||
---
|
||||
|
||||
1. clone repo
|
||||
2. `pnpm install`
|
||||
3. `cp apps/web/.env.example apps/web/.env && cp packages/db/.env.example packages/db/.env`
|
||||
4. `pnpm dev`
|
||||
5. `pnpm db:migrate` (RUN THIS AFTER POPULATING ENV)
|
||||
|
||||
- slack notifier app manifest is as follows:
|
||||
```
|
||||
display_information:
|
||||
# please change the name to something that can be linked to you if possible
|
||||
name: hctv notifier dev
|
||||
features:
|
||||
bot_user:
|
||||
# same with this :pray:
|
||||
display_name: hctv notifier dev
|
||||
always_online: false
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- chat:write
|
||||
- users:read
|
||||
- channels:join
|
||||
settings:
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
31
apps/web/.env.example
Normal file
31
apps/web/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
DATABASE_URL=postgresql://postgres:skbiditoilet@localhost:5555/postgres
|
||||
|
||||
# make a slack app here: https://api.slack.com/apps
|
||||
SLACK_NOTIFIER_TOKEN=<make a bot for this, check app manifest below>
|
||||
|
||||
# invite your bot to the channel you created. below is #hctv-dev, so use that if you want!
|
||||
NOTIFICATION_CHANNEL_ID=C08M3MGE6PJ
|
||||
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# get from https://uploadthing.com/
|
||||
UPLOADTHING_TOKEN=<get from uploadthing>
|
||||
|
||||
# enable oauth mode on your hca account and make an app: https://auth.hackclub.com/identity/edit
|
||||
HCID_CLIENT=<auth.hackclub.com client>
|
||||
HCID_SECRET=<auth.hackclub.com secret>
|
||||
# make sure to put this as one of the redirect uri
|
||||
HCID_REDIRECT_URI=http://localhost:3000/auth/hackclub/callback
|
||||
|
||||
# mediamtx settings
|
||||
NEXT_PUBLIC_MEDIAMTX_URL_HQ=http://localhost:8891
|
||||
MEDIAMTX_API_HQ=http://localhost:9997
|
||||
NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ=localhost:8890
|
||||
|
||||
# commented because we don't have another ingest server as of right now
|
||||
# NEXT_PUBLIC_MEDIAMTX_URL_ASIA=http://localhost:8991
|
||||
# MEDIAMTX_API_ASIA=http://localhost:9999
|
||||
# NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ASIA=localhost:8990
|
||||
|
||||
# idt you should change this
|
||||
MEDIAMTX_PUBLISH_KEY=rjq1xdpCPA4qyt3jge
|
||||
@@ -1,4 +1,7 @@
|
||||
FROM node:lts-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -7,9 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
# RUN yarn global add turbo@^2
|
||||
RUN yarn global add turbo@^2
|
||||
RUN pnpm add -g turbo@^2
|
||||
COPY . .
|
||||
|
||||
# Get the git commit hash before pruning (since .git might be removed)
|
||||
@@ -35,12 +36,13 @@ WORKDIR /app
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY --from=builder /app/out/json/ .
|
||||
RUN yarn install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM \
|
||||
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM --mount=type=secret,id=SENTRY_AUTH_TOKEN \
|
||||
COMMIT=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) \
|
||||
SENTRY_AUTH_TOKEN=$(cat /run/secrets/SENTRY_AUTH_TOKEN) \
|
||||
commit=$COMMIT yarn turbo run build --env-mode=loose
|
||||
|
||||
FROM base AS runner
|
||||
@@ -65,7 +67,7 @@ RUN COMMIT_VALUE=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
|
||||
echo "#!/bin/sh" > /usr/local/bin/start.sh && \
|
||||
echo "set -e" >> /usr/local/bin/start.sh && \
|
||||
echo "echo 'Running database migrations...'" >> /usr/local/bin/start.sh && \
|
||||
echo "npx prisma migrate deploy --schema=/app/packages/db/prisma/schema.prisma" >> /usr/local/bin/start.sh && \
|
||||
echo "pnpm prisma migrate deploy --schema=/app/packages/db/prisma/schema.prisma" >> /usr/local/bin/start.sh && \
|
||||
echo "cd /app" >> /usr/local/bin/start.sh && \
|
||||
echo "export commit=$COMMIT_VALUE" >> /usr/local/bin/start.sh && \
|
||||
echo "echo 'Starting Next.js application...'" >> /usr/local/bin/start.sh && \
|
||||
|
||||
@@ -69,6 +69,9 @@ export default withSentryConfig(nextConfig, {
|
||||
|
||||
project: "hctv",
|
||||
|
||||
// Auth token for uploading source maps
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
@@ -78,6 +81,9 @@ export default withSentryConfig(nextConfig, {
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dd": "docker compose --file ../../dev/docker-compose.yml up -d",
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev --turbo -H 0.0.0.0",
|
||||
"donly": "docker compose --file ../../dev/docker-compose.yml up",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
@@ -15,8 +15,8 @@
|
||||
"genMtxTypes": "bunx openapi-typescript https://github.com/bluenviron/mediamtx/raw/refs/tags/v1.15.5/api/openapi.yaml -o ./src/lib/types/mediamtx.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/auth": "*",
|
||||
"@hctv/db": "*",
|
||||
"@hctv/auth": "workspace:*",
|
||||
"@hctv/db": "workspace:*",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
@@ -47,7 +47,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls-video-element": "^1.5.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucia": "^3.2.2",
|
||||
"lucide-react": "^0.473.0",
|
||||
"media-chrome": "^4.8.0",
|
||||
@@ -58,6 +60,7 @@
|
||||
"pg": "^8.14.1",
|
||||
"pg-boss": "^10.1.6",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -5,11 +5,34 @@ export default async function Page({ params }: { params: Promise<{ username: str
|
||||
const { username } = await params;
|
||||
const streamInfo = await prisma.streamInfo.findUnique({
|
||||
where: { username },
|
||||
include: { channel: true },
|
||||
include: {
|
||||
channel: {
|
||||
include: {
|
||||
restriction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!streamInfo) {
|
||||
return <div>Stream not found</div>;
|
||||
}
|
||||
|
||||
if (streamInfo.channel.restriction) {
|
||||
const isExpired = streamInfo.channel.restriction.expiresAt &&
|
||||
new Date(streamInfo.channel.restriction.expiresAt) < new Date();
|
||||
|
||||
if (!isExpired) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[calc(100vh-64px)] p-4">
|
||||
<h1 className="text-2xl font-bold text-destructive mb-2">Channel Restricted</h1>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
This channel has been restricted by a moderator and is not currently available for viewing.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LiveStream username={username} streamInfo={streamInfo} />
|
||||
);
|
||||
|
||||
792
apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
Normal file
792
apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
Normal file
@@ -0,0 +1,792 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Users,
|
||||
Tv,
|
||||
Ban,
|
||||
ShieldOff,
|
||||
Search,
|
||||
CalendarIcon,
|
||||
ShieldCheck,
|
||||
ShieldMinus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { User } from '@hctv/db';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) {
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
const [channelSearch, setChannelSearch] = useState('');
|
||||
const [users, setUsers] = useState<UserWithBan[]>([]);
|
||||
const [channels, setChannels] = useState<ChannelWithRestriction[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
const [channelsLoading, setChannelsLoading] = useState(false);
|
||||
|
||||
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
||||
const [restrictDialogOpen, setRestrictDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserWithBan | null>(null);
|
||||
const [selectedChannel, setSelectedChannel] = useState<ChannelWithRestriction | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState<Date | undefined>(undefined);
|
||||
|
||||
const fetchUsers = useCallback(async (search: string) => {
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users?search=${encodeURIComponent(search)}`);
|
||||
if (res.ok) {
|
||||
setUsers(await res.json());
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to fetch users');
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchChannels = useCallback(async (search: string) => {
|
||||
setChannelsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/channels?search=${encodeURIComponent(search)}`);
|
||||
if (res.ok) {
|
||||
setChannels(await res.json());
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to fetch channels');
|
||||
} finally {
|
||||
setChannelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers('');
|
||||
fetchChannels('');
|
||||
}, [fetchUsers, fetchChannels]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchUsers(userSearch);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [userSearch, fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchChannels(channelSearch);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [channelSearch, fetchChannels]);
|
||||
|
||||
const resetDialogState = () => {
|
||||
setReason('');
|
||||
setExpiresAt(undefined);
|
||||
setSelectedUser(null);
|
||||
setSelectedChannel(null);
|
||||
};
|
||||
|
||||
const handleBanUser = async () => {
|
||||
if (!selectedUser || !reason.trim()) {
|
||||
toast.error('Please provide a reason');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: selectedUser.id,
|
||||
action: 'ban',
|
||||
reason,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('User banned successfully');
|
||||
fetchUsers(userSearch);
|
||||
setBanDialogOpen(false);
|
||||
resetDialogState();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
toast.error(err || 'Failed to ban user');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to ban user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbanUser = async (userId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
action: 'unban',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('User unbanned successfully');
|
||||
fetchUsers(userSearch);
|
||||
} else {
|
||||
toast.error('Failed to unban user');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to unban user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestrictChannel = async () => {
|
||||
if (!selectedChannel || !reason.trim()) {
|
||||
toast.error('Please provide a reason');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId: selectedChannel.id,
|
||||
action: 'restrict',
|
||||
reason,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('Channel restricted successfully');
|
||||
fetchChannels(channelSearch);
|
||||
setRestrictDialogOpen(false);
|
||||
resetDialogState();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
toast.error(err || 'Failed to restrict channel');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to restrict channel');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnrestrictChannel = async (channelId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId,
|
||||
action: 'unrestrict',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('Channel unrestricted successfully');
|
||||
fetchChannels(channelSearch);
|
||||
} else {
|
||||
toast.error('Failed to unrestrict channel');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to unrestrict channel');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteUser = async (userId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
action: 'promote',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('User promoted to admin');
|
||||
fetchUsers(userSearch);
|
||||
} else {
|
||||
const err = await res.text();
|
||||
toast.error(err || 'Failed to promote user');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to promote user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemoteUser = async (userId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
action: 'demote',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('User demoted from admin');
|
||||
fetchUsers(userSearch);
|
||||
} else {
|
||||
const err = await res.text();
|
||||
toast.error(err || 'Failed to demote user');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Failed to demote user');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-6xl mx-auto py-6 px-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
Admin Panel
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Manage users and channels on the platform</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels" className="flex items-center gap-2">
|
||||
<Tv className="h-4 w-4" />
|
||||
Channels
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Management</CardTitle>
|
||||
<CardDescription>
|
||||
Search and manage user accounts. Ban users to prevent them from using the platform.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by username or email..."
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usersLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user.pfpUrl} />
|
||||
<AvatarFallback>
|
||||
{user.personalChannel?.name?.[0]?.toUpperCase() || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{user.personalChannel?.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{user.isAdmin && (
|
||||
<Badge variant="default">Admin</Badge>
|
||||
)}
|
||||
{user.ban ? (
|
||||
<Badge variant="destructive" className="flex items-center gap-1">
|
||||
<Ban className="h-3 w-3" />
|
||||
Banned
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user.ban && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<p>Reason: {user.ban.reason}</p>
|
||||
{user.ban.expiresAt && (
|
||||
<p>Expires: {format(new Date(user.ban.expiresAt), 'PPP')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{user.isAdmin ? (
|
||||
user.id !== currentUser.id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDemoteUser(user.id)}
|
||||
>
|
||||
<ShieldMinus className="h-4 w-4 mr-1" />
|
||||
Demote
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{user.ban ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnbanUser(user.id)}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4 mr-1" />
|
||||
Unban
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setBanDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-1" />
|
||||
Ban
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePromoteUser(user.id)}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 mr-1" />
|
||||
Promote
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Channel Management</CardTitle>
|
||||
<CardDescription>
|
||||
Search and manage channels. Restrict channels to prevent streams from being viewed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by channel name..."
|
||||
value={channelSearch}
|
||||
onChange={(e) => setChannelSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Owner</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channelsLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : channels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
No channels found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={channel.pfpUrl} />
|
||||
<AvatarFallback>
|
||||
{channel.name[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{channel.name}</p>
|
||||
{channel.personalFor && (
|
||||
<Badge variant="outline" className="text-xs">Personal</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={channel.owner.pfpUrl} />
|
||||
<AvatarFallback>O</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{channel.owner.personalChannel.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{channel.restriction ? (
|
||||
<Badge variant="destructive" className="flex items-center gap-1 w-fit">
|
||||
<Ban className="h-3 w-3" />
|
||||
Restricted
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
)}
|
||||
{channel.restriction && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<p>Reason: {channel.restriction.reason}</p>
|
||||
{channel.restriction.expiresAt && (
|
||||
<p>Expires: {format(new Date(channel.restriction.expiresAt), 'PPP')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{channel.restriction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnrestrictChannel(channel.id)}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4 mr-1" />
|
||||
Unrestrict
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedChannel(channel);
|
||||
setRestrictDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-1" />
|
||||
Restrict
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={banDialogOpen} onOpenChange={(open) => {
|
||||
setBanDialogOpen(open);
|
||||
if (!open) resetDialogState();
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ban User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ban {selectedUser?.personalChannel?.name || selectedUser?.slack_id} from the platform.
|
||||
They will not be able to stream or use the platform while banned.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Reason</label>
|
||||
<Textarea
|
||||
placeholder="Enter the reason for banning this user..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Expires (optional)</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Leave empty for a permanent ban
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'flex-1 justify-start text-left font-normal',
|
||||
!expiresAt && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{expiresAt ? format(expiresAt, 'PPP p') : 'Pick a date & time'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={expiresAt}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
const newDate = expiresAt ? new Date(expiresAt) : new Date();
|
||||
date.setHours(newDate.getHours(), newDate.getMinutes());
|
||||
setExpiresAt(date);
|
||||
} else {
|
||||
setExpiresAt(undefined);
|
||||
}
|
||||
}}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<label className="text-sm font-medium">Time</label>
|
||||
<Input
|
||||
type="time"
|
||||
className="mt-1"
|
||||
value={expiresAt ? format(expiresAt, 'HH:mm') : ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
const [hours, minutes] = e.target.value.split(':').map(Number);
|
||||
const newDate = expiresAt ? new Date(expiresAt) : new Date();
|
||||
newDate.setHours(hours, minutes);
|
||||
setExpiresAt(newDate);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{expiresAt && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setExpiresAt(undefined)}
|
||||
>
|
||||
<X className='w-4 h-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setBanDialogOpen(false);
|
||||
resetDialogState();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleBanUser}>
|
||||
Ban User
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={restrictDialogOpen} onOpenChange={(open) => {
|
||||
setRestrictDialogOpen(open);
|
||||
if (!open) resetDialogState();
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restrict Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Restrict {selectedChannel?.name} from streaming. Viewers will not be able to watch
|
||||
streams from this channel.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Reason</label>
|
||||
<Textarea
|
||||
placeholder="Enter the reason for restricting this channel..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Expires (optional)</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Leave empty for a permanent restriction
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'flex-1 justify-start text-left font-normal',
|
||||
!expiresAt && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{expiresAt ? format(expiresAt, 'PPP p') : 'Pick a date & time'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={expiresAt}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
const newDate = expiresAt ? new Date(expiresAt) : new Date();
|
||||
date.setHours(newDate.getHours(), newDate.getMinutes());
|
||||
setExpiresAt(date);
|
||||
} else {
|
||||
setExpiresAt(undefined);
|
||||
}
|
||||
}}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<label className="text-sm font-medium">Time</label>
|
||||
<Input
|
||||
type="time"
|
||||
className="mt-1"
|
||||
value={expiresAt ? format(expiresAt, 'HH:mm') : ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
const [hours, minutes] = e.target.value.split(':').map(Number);
|
||||
const newDate = expiresAt ? new Date(expiresAt) : new Date();
|
||||
newDate.setHours(hours, minutes);
|
||||
setExpiresAt(newDate);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{expiresAt && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setExpiresAt(undefined)}
|
||||
>
|
||||
<X className='w-4 h-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setRestrictDialogOpen(false);
|
||||
resetDialogState();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleRestrictChannel}>
|
||||
Restrict Channel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserWithBan {
|
||||
id: string;
|
||||
slack_id: string;
|
||||
email: string | null;
|
||||
pfpUrl: string;
|
||||
isAdmin: boolean;
|
||||
ban: {
|
||||
id: string;
|
||||
reason: string;
|
||||
bannedBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
} | null;
|
||||
personalChannel: { name: string } | null;
|
||||
}
|
||||
|
||||
interface ChannelWithRestriction {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pfpUrl: string;
|
||||
owner: { id: string; slack_id: string; pfpUrl: string; personalChannel: { name: string } };
|
||||
personalFor: { id: string } | null;
|
||||
restriction: {
|
||||
id: string;
|
||||
reason: string;
|
||||
restrictedBy: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AdminPanelClientProps {
|
||||
currentUser: User;
|
||||
}
|
||||
|
||||
17
apps/web/src/app/(ui)/(protected)/admin/page.tsx
Normal file
17
apps/web/src/app/(ui)/(protected)/admin/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect } from 'next/navigation';
|
||||
import AdminPanelClient from './page.client';
|
||||
|
||||
export default async function AdminPage() {
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/hackclub');
|
||||
}
|
||||
|
||||
if (!user.isAdmin) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
return <AdminPanelClient currentUser={user} />;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.isAdmin) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const search = searchParams.get('search') || '';
|
||||
|
||||
const channels = await prisma.channel.findMany({
|
||||
where: search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
include: {
|
||||
restriction: true,
|
||||
owner: {
|
||||
select: { id: true, slack_id: true, pfpUrl: true, personalChannel: { select: { name: true } } },
|
||||
},
|
||||
personalFor: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
return Response.json(channels);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.isAdmin) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { channelId, action, reason, expiresAt } = body as {
|
||||
channelId: string;
|
||||
action: 'restrict' | 'unrestrict';
|
||||
reason?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
if (!channelId || !action) {
|
||||
return new Response('Missing required fields', { status: 400 });
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
return new Response('Channel not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (action === 'restrict') {
|
||||
if (!reason) {
|
||||
return new Response('Reason is required for restricting', { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.channelRestriction.upsert({
|
||||
where: { channelId },
|
||||
update: {
|
||||
reason,
|
||||
restrictedBy: user.id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
create: {
|
||||
channelId,
|
||||
reason,
|
||||
restrictedBy: user.id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: 'Channel restricted' });
|
||||
}
|
||||
|
||||
if (action === 'unrestrict') {
|
||||
await prisma.channelRestriction.delete({ where: { channelId } }).catch(() => { });
|
||||
return Response.json({ success: true, message: 'Channel unrestricted' });
|
||||
}
|
||||
|
||||
return new Response('Invalid action', { status: 400 });
|
||||
}
|
||||
119
apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts
Normal file
119
apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.isAdmin) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const search = searchParams.get('search') || '';
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: search
|
||||
? {
|
||||
OR: [
|
||||
{ slack_id: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ personalChannel: { name: { contains: search, mode: 'insensitive' } } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
include: {
|
||||
ban: true,
|
||||
personalChannel: { select: { name: true } },
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
return Response.json(users);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user?.isAdmin) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, action, reason, expiresAt } = body as {
|
||||
userId: string;
|
||||
action: 'ban' | 'unban' | 'promote' | 'demote';
|
||||
reason?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
if (!userId || !action) {
|
||||
return new Response('Missing required fields', { status: 400 });
|
||||
}
|
||||
|
||||
const targetUser = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!targetUser) {
|
||||
return new Response('User not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (action === 'ban') {
|
||||
if (targetUser.isAdmin) {
|
||||
return new Response('Cannot ban an admin', { status: 400 });
|
||||
}
|
||||
|
||||
if (!reason) {
|
||||
return new Response('Reason is required for banning', { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.userBan.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
reason,
|
||||
bannedBy: user.id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
reason,
|
||||
bannedBy: user.id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: 'User banned' });
|
||||
}
|
||||
|
||||
if (action === 'unban') {
|
||||
await prisma.userBan.delete({ where: { userId } }).catch(() => { });
|
||||
return Response.json({ success: true, message: 'User unbanned' });
|
||||
}
|
||||
|
||||
if (action === 'promote') {
|
||||
if (targetUser.isAdmin) {
|
||||
return new Response('User is already an admin', { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isAdmin: true },
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: 'User promoted to admin' });
|
||||
}
|
||||
|
||||
if (action === 'demote') {
|
||||
if (!targetUser.isAdmin) {
|
||||
return new Response('User is not an admin', { status: 400 });
|
||||
}
|
||||
|
||||
if (targetUser.id === user.id) {
|
||||
return new Response('Cannot demote yourself', { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isAdmin: false },
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: 'User demoted from admin' });
|
||||
}
|
||||
|
||||
return new Response('Invalid action', { status: 400 });
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { prisma, getRedisConnection } from '@hctv/db';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { lucia } from '@hctv/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const redis = getRedisConnection();
|
||||
const body = await request.json();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(
|
||||
'Mediamtx publish auth request:',
|
||||
JSON.stringify(body, null, 2)
|
||||
);
|
||||
};
|
||||
const parsed = schema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -20,11 +25,43 @@ export async function POST(request: NextRequest) {
|
||||
if (channelKey !== password) {
|
||||
return new Response('invalid stream key', { status: 403 });
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { name: path },
|
||||
include: {
|
||||
restriction: true,
|
||||
owner: {
|
||||
include: { ban: true },
|
||||
},
|
||||
streamInfo: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (channel?.restriction) {
|
||||
const isExpired = channel.restriction.expiresAt &&
|
||||
new Date(channel.restriction.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return new Response('channel restricted', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (channel?.owner?.ban) {
|
||||
const isExpired = channel.owner.ban.expiresAt &&
|
||||
new Date(channel.owner.ban.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return new Response('user banned', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (channel?.streamInfo[0].isLive) {
|
||||
return new Response('stream already live', { status: 403 });
|
||||
}
|
||||
|
||||
return new Response('youre in yay', { status: 200 });
|
||||
}
|
||||
} else if (action === 'read' && protocol === 'hls') {
|
||||
if (password === process.env.MEDIAMTX_PUBLISH_KEY) {
|
||||
return new Response('authorized', { status: 200 });
|
||||
return new Response('authorized (hls read key for thumbs)', { status: 200 });
|
||||
}
|
||||
const sessionExists = await redis.exists(`sessions:${password}`);
|
||||
if (!sessionExists) {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import fsP from 'fs/promises';
|
||||
import fs from 'fs';
|
||||
import { getRedisConnection } from '@hctv/db';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
|
||||
const { path } = await params;
|
||||
const c = await cookies();
|
||||
|
||||
const sessionCookie = c.get('auth_session')?.value;
|
||||
if (!sessionCookie) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const sessionExists = await getRedisConnection().exists(`sessions:${sessionCookie}`);
|
||||
if (sessionExists === 0) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (path.includes('..')) {
|
||||
return new Response("nuh uh", { status: 403 });
|
||||
}
|
||||
|
||||
const basePath = '/dev/shm/hls';
|
||||
const filePath = `${basePath}/${path}`;
|
||||
const exists = fs.existsSync(filePath);
|
||||
|
||||
if (!exists) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const file = await fsP.readFile(filePath);
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,14 @@ export async function POST(request: NextRequest) {
|
||||
key: streamKey,
|
||||
},
|
||||
include: {
|
||||
channel: true,
|
||||
channel: {
|
||||
include: {
|
||||
restriction: true,
|
||||
owner: {
|
||||
include: { ban: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +31,23 @@ export async function POST(request: NextRequest) {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (key.channel.restriction) {
|
||||
const isExpired = key.channel.restriction.expiresAt &&
|
||||
new Date(key.channel.restriction.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return new Response('channel restricted', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (key.channel.owner?.ban) {
|
||||
const isExpired = key.channel.owner.ban.expiresAt &&
|
||||
new Date(key.channel.owner.ban.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return new Response('user banned', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function GET(request: NextRequest) {
|
||||
const shouldGetOwned = searchParams.get('owned') === 'true';
|
||||
const allPersonalChannels = searchParams.get('personal') === 'true';
|
||||
const isLive = searchParams.get('live') === 'true';
|
||||
const username = searchParams.get('username');
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if ((shouldGetOwned || allPersonalChannels) && !user) {
|
||||
@@ -18,6 +19,10 @@ export async function GET(request: NextRequest) {
|
||||
const where: Prisma.StreamInfoWhereInput = {};
|
||||
const channelConditions: Prisma.ChannelWhereInput[] = [];
|
||||
|
||||
if (username) {
|
||||
where.username = username;
|
||||
}
|
||||
|
||||
if (shouldGetOwned && user) {
|
||||
channelConditions.push({ ownerId: user.id });
|
||||
}
|
||||
@@ -46,6 +51,12 @@ export async function GET(request: NextRequest) {
|
||||
channel: {
|
||||
include: {
|
||||
personalFor: true,
|
||||
restriction: {
|
||||
select: {
|
||||
id: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -58,6 +69,22 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
// @ts-ignore
|
||||
delete obj.channel.obsChatGrantToken;
|
||||
|
||||
if (obj.channel.restriction) {
|
||||
const isExpired = obj.channel.restriction.expiresAt &&
|
||||
new Date(obj.channel.restriction.expiresAt) < new Date();
|
||||
if (isExpired) {
|
||||
// @ts-ignore
|
||||
obj.channel.restriction = null;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
obj.channel.isRestricted = true;
|
||||
// @ts-ignore
|
||||
obj.channel.restrictionExpiresAt = obj.channel.restriction.expiresAt;
|
||||
// @ts-ignore
|
||||
delete obj.channel.restriction;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Response.json(db);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { redirect, RedirectType } from 'next/navigation';
|
||||
import { prisma } from '@hctv/db';
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user } = await validateRequest();
|
||||
@@ -9,5 +10,33 @@ export default async function Layout({ children }: { children: React.ReactNode }
|
||||
if (!user.hasOnboarded) {
|
||||
return redirect(`/onboarding`, RedirectType.push);
|
||||
}
|
||||
|
||||
const ban = await prisma.userBan.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (ban) {
|
||||
const isExpired = ban.expiresAt && new Date(ban.expiresAt) < new Date();
|
||||
if (!isExpired) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h1 className="text-3xl font-bold text-destructive mb-4">Account Suspended</h1>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-4">
|
||||
Your account has been suspended from hackclub.tv.
|
||||
</p>
|
||||
<div className="bg-muted p-4 rounded-lg max-w-md">
|
||||
<p className="text-sm font-medium">Reason:</p>
|
||||
<p className="text-sm text-muted-foreground">{ban.reason}</p>
|
||||
</div>
|
||||
{ban.expiresAt && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Expires: {new Date(ban.expiresAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getBotBySlug } from '@/lib/db/resolve';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { can } from '@/lib/auth/abac';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { GeneralSettings } from '@/app/(ui)/(protected)/settings/bot/[slug]/gensettings';
|
||||
@@ -12,7 +13,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
const { slug } = await params;
|
||||
const bot = await getBotBySlug(slug);
|
||||
|
||||
if (!bot || bot.ownerId !== user?.id) {
|
||||
if (!user || !bot || !can(user, 'update', 'bot', { bot })) {
|
||||
redirect('/settings/bot');
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
deleteChannel,
|
||||
toggleGlobalChannelNotifs,
|
||||
editStreamInfo,
|
||||
changeUsername,
|
||||
} from '@/lib/form/actions';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
@@ -53,6 +54,15 @@ import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useConfirm } from '@omit/react-confirm-dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
||||
import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions';
|
||||
|
||||
interface ChannelSettingsClientProps {
|
||||
channel: Channel & {
|
||||
@@ -65,6 +75,7 @@ interface ChannelSettingsClientProps {
|
||||
followers: (Follow & { user: { id: string; slack_id: string } })[];
|
||||
followerPersonalChannels: (Channel | null)[];
|
||||
is247: boolean;
|
||||
nameLastChanged: Date | null;
|
||||
};
|
||||
isOwner: boolean;
|
||||
currentUser: User;
|
||||
@@ -87,6 +98,7 @@ export default function ChannelSettingsClient({
|
||||
const [selTab, setSelTab] = useQueryState('tab', parseAsString.withDefault('general'));
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [region, setRegion] = useState<MediaMTXRegion>('hq');
|
||||
const channelList = useOwnedChannels();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -102,6 +114,32 @@ export default function ChannelSettingsClient({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsernameChangeComplete = useCallback(
|
||||
(result: any) => {
|
||||
if (result?.success && result?.newUsername) {
|
||||
toast.success('Username changed successfully! Redirecting...');
|
||||
router.push(`/settings/channel/${result.newUsername}?tab=${selTab}`);
|
||||
}
|
||||
},
|
||||
[router, selTab]
|
||||
);
|
||||
|
||||
const getUsernameChangeCooldownInfo = () => {
|
||||
if (!channel.nameLastChanged) {
|
||||
return { canChange: true, daysRemaining: 0 };
|
||||
}
|
||||
const daysSinceLastChange = Math.floor(
|
||||
(Date.now() - new Date(channel.nameLastChanged).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const cooldownDays = 30;
|
||||
if (daysSinceLastChange >= cooldownDays) {
|
||||
return { canChange: true, daysRemaining: 0 };
|
||||
}
|
||||
return { canChange: false, daysRemaining: cooldownDays - daysSinceLastChange };
|
||||
};
|
||||
|
||||
const cooldownInfo = getUsernameChangeCooldownInfo();
|
||||
|
||||
const copyStreamKey = async () => {
|
||||
if (streamKey) {
|
||||
await navigator.clipboard.writeText(streamKey);
|
||||
@@ -136,7 +174,8 @@ export default function ChannelSettingsClient({
|
||||
toast.error('Stream key not available');
|
||||
return '';
|
||||
}
|
||||
return `srt://${process.env.NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE}?streamid=publish:${channel.name}:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:${streamKey}&pkt_size=1316`;
|
||||
const { ingestRoute } = getMediamtxClientEnvs(region);
|
||||
return `srt://${ingestRoute}?streamid=publish:${channel.name}:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:${streamKey}&pkt_size=1316`;
|
||||
};
|
||||
|
||||
const copyStreamUrl = async () => {
|
||||
@@ -168,10 +207,10 @@ export default function ChannelSettingsClient({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1' />
|
||||
<div className="flex-1" />
|
||||
<div>
|
||||
<ChannelSelect
|
||||
channelList={channelList.channels.map(c => c.channel)}
|
||||
channelList={channelList.channels.map((c) => c.channel)}
|
||||
value={channel.name}
|
||||
onSelect={(value) => {
|
||||
if (value === 'create') {
|
||||
@@ -205,7 +244,7 @@ export default function ChannelSettingsClient({
|
||||
Notifications
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="utilities" className="flex items-center gap-2">
|
||||
<Wrench className='size-4' />
|
||||
<Wrench className="size-4" />
|
||||
Utilities
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -231,7 +270,7 @@ export default function ChannelSettingsClient({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input type="hidden" {...field} />
|
||||
|
||||
|
||||
{field.value && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
@@ -240,7 +279,9 @@ export default function ChannelSettingsClient({
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Current profile picture</p>
|
||||
<p className="text-xs text-muted-foreground">Click "Upload new image" to replace</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click "Upload new image" to replace
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -255,14 +296,14 @@ export default function ChannelSettingsClient({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
<UploadButton
|
||||
endpoint="pfpUpload"
|
||||
className="mt-2 ut-button:bg-mantle ut-button:text-mantle-foreground ut-allowed-content:text-muted-foreground/70"
|
||||
content={{
|
||||
button: field.value ? "Upload new image" : "Upload profile picture",
|
||||
allowedContent: "Image (1MB max)"
|
||||
button: field.value ? 'Upload new image' : 'Upload profile picture',
|
||||
allowedContent: 'Image (1MB max)',
|
||||
}}
|
||||
onUploadBegin={() => {
|
||||
setIsUploading(true);
|
||||
@@ -282,19 +323,15 @@ export default function ChannelSettingsClient({
|
||||
}}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
|
||||
{isUploading && (
|
||||
<p className="mt-2 text-sm text-primary">
|
||||
Uploading...
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-primary">Uploading...</p>
|
||||
)}
|
||||
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{uploadError}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-red-600">{uploadError}</p>
|
||||
)}
|
||||
|
||||
|
||||
{!field.value && !isUploading && !uploadError && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Upload a profile picture for your channel.
|
||||
@@ -340,7 +377,8 @@ export default function ChannelSettingsClient({
|
||||
<div>
|
||||
<label className="text-sm font-medium">24/7 Channel</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mark this channel as always live. It will disable notifications on #hctv-streams.
|
||||
Mark this channel as always live. It will disable notifications on
|
||||
#hctv-streams.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -352,7 +390,7 @@ export default function ChannelSettingsClient({
|
||||
<input type="hidden" {...field} value={field.value ? 'true' : 'false'} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
},
|
||||
]}
|
||||
schemaName="updateChannelSettings"
|
||||
action={updateChannelSettings}
|
||||
@@ -360,6 +398,48 @@ export default function ChannelSettingsClient({
|
||||
onActionComplete={handleChannelSettingsActionComplete}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isPersonal && isOwner && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">Username</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your username is how others find and mention you on hctv. You can change it once
|
||||
every 30 days.
|
||||
</p>
|
||||
{!cooldownInfo.canChange && (
|
||||
<div className="p-3 border border-accent/20 rounded-lg bg-accent/5">
|
||||
<p className="text-sm text-accent">
|
||||
You can change your username again in {cooldownInfo.daysRemaining} day
|
||||
{cooldownInfo.daysRemaining === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
|
||||
{
|
||||
name: 'newUsername',
|
||||
label: 'New Username',
|
||||
type: 'text',
|
||||
value: '',
|
||||
placeholder: channel.name,
|
||||
description:
|
||||
'Only lowercase letters, numbers, underscores, and dashes. Max 20 characters.',
|
||||
inputFilter: /[^a-z0-9_-]/g,
|
||||
maxChars: 20,
|
||||
},
|
||||
]}
|
||||
schemaName="changeUsername"
|
||||
action={changeUsername}
|
||||
submitText="Change Username"
|
||||
onActionComplete={handleUsernameChangeComplete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwner && !isPersonal && (
|
||||
<>
|
||||
<Separator />
|
||||
@@ -430,7 +510,11 @@ export default function ChannelSettingsClient({
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{keyVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{keyVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={regenerateStreamKey} variant="outline" size="smicon">
|
||||
@@ -442,13 +526,27 @@ export default function ChannelSettingsClient({
|
||||
onClick={copyStreamKey}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied.streamKey ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Stream URL (for OBS)</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Stream URL (for OBS)</label>
|
||||
<Select value={region} onValueChange={(v) => setRegion(v as MediaMTXRegion)}>
|
||||
<SelectTrigger className="w-[180px] h-8">
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hq">HQ Server A 🇺🇸</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
@@ -464,7 +562,11 @@ export default function ChannelSettingsClient({
|
||||
onClick={copyStreamUrl}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamUrl ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied.streamUrl ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,7 +574,7 @@ export default function ChannelSettingsClient({
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Need help getting started? Check out our{' '}
|
||||
<Link
|
||||
href="https://docs.hackclub.tv/guides/start-stream/"
|
||||
href="https://docs.hackclub.tv/guides/start-stream/"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -594,12 +696,14 @@ export default function ChannelSettingsClient({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (await confirm({
|
||||
title: 'Remove Manager',
|
||||
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
})) {
|
||||
if (
|
||||
await confirm({
|
||||
title: 'Remove Manager',
|
||||
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
) {
|
||||
removeChannelManager(channel.id, manager.id);
|
||||
}
|
||||
}}
|
||||
@@ -705,7 +809,7 @@ export default function ChannelSettingsClient({
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Chat overlay</h3>
|
||||
<p className="text-sm text-mantle-foreground mb-4">
|
||||
Add a 300x600 browser source with this and enjoy!
|
||||
Add a 300x600 browser source with this and enjoy!
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from '@hctv/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import ChannelSettingsClient from './page.client';
|
||||
import { resolvePersonalChannel } from '@/lib/auth/resolve';
|
||||
import { can } from '@/lib/auth/abac';
|
||||
|
||||
export default async function ChannelSettingsPage({
|
||||
params,
|
||||
@@ -42,9 +43,8 @@ export default async function ChannelSettingsPage({
|
||||
}
|
||||
|
||||
const isOwner = channel.ownerId === user.id;
|
||||
const isManager = channel.managers.some((manager) => manager.id === user.id);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
if (!can(user, 'update', 'channel', { channel })) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,9 @@ export default function OnboardingClient() {
|
||||
name: 'username',
|
||||
label: 'Channel Username',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., yourname or yourname-codes'
|
||||
placeholder: 'e.g., yourname or yourname-codes',
|
||||
maxChars: 20,
|
||||
inputFilter: /[^a-z0-9_-]/g,
|
||||
},
|
||||
]}
|
||||
schemaName="onboard"
|
||||
@@ -119,7 +121,7 @@ export default function OnboardingClient() {
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Username rules:</strong> Only lowercase letters (a-z), numbers (0-9),
|
||||
underscores (_), and dashes (-) are allowed. Must be unique across the platform.
|
||||
underscores (_), and dashes (-) are allowed. Up to 20 characters. Must be unique across the platform.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -4,7 +4,10 @@ import OnboardingClient from "./page.client";
|
||||
|
||||
export default async function Page() {
|
||||
const { user } = await validateRequest();
|
||||
if (user!.hasOnboarded) {
|
||||
if (!user) {
|
||||
return redirect('/');
|
||||
}
|
||||
if (user.hasOnboarded) {
|
||||
return redirect('/');
|
||||
}
|
||||
return <OnboardingClient />;
|
||||
|
||||
@@ -2,8 +2,22 @@ import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import './globals.css';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const publicEnv = Object.keys(process.env).reduce((acc, key) => {
|
||||
if (key.startsWith('NEXT_PUBLIC_')) {
|
||||
acc[key] = process.env[key];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string | undefined>);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.__ENV = ${JSON.stringify(publicEnv)}`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<NuqsAdapter>
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
|
||||
@@ -245,9 +245,14 @@ export default function ChatPanel(props: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${props.isObsPanel ? 'w-full text-white' : 'md:border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}>
|
||||
<div ref={scrollRef} className={`flex-1 p-4 ${props.isObsPanel ? 'scrollbar-hide' : ''} overflow-y-auto flex flex-col`}>
|
||||
<div className="space-y-4 flex-1">
|
||||
<div
|
||||
className={`${props.isObsPanel ? 'w-full text-white' : 'md:border-l border-border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`flex-1 px-4 py-2 ${props.isObsPanel ? 'scrollbar-hide' : 'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'} overflow-y-auto overflow-x-hidden`}
|
||||
>
|
||||
<div className="space-y-1 min-h-full flex flex-col justify-end">
|
||||
{chatMessages.map((msg, i) => (
|
||||
<Message
|
||||
key={i}
|
||||
@@ -260,8 +265,8 @@ export default function ChatPanel(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
{!props.isObsPanel && (
|
||||
<div className="p-4 border-t relative">
|
||||
<div className="flex space-x-2">
|
||||
<div className="p-3 border-t border-border relative">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
@@ -281,11 +286,16 @@ export default function ChatPanel(props: Props) {
|
||||
onClick={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
placeholder="Type a message"
|
||||
className="flex-1 bg-transparent focus-visible:ring-offset-0 min-h-[40px] max-h-[120px] resize-none py-2"
|
||||
placeholder="Send a message..."
|
||||
className="flex-1 bg-background/50 border-border focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-0 min-h-[40px] max-h-[100px] resize-none py-2 text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
|
||||
<Button
|
||||
size="icon"
|
||||
className="shrink-0 transition-colors"
|
||||
onClick={sendMessage}
|
||||
disabled={!message.trim()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -28,25 +28,27 @@ export function EmojiSearch({
|
||||
useEffect(() => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
|
||||
|
||||
if (match) {
|
||||
const term = match[0].substring(1);
|
||||
setSearchTerm(term);
|
||||
|
||||
|
||||
if (term.length > 0) {
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter(name => name.toLowerCase().includes(term.toLowerCase()))
|
||||
.filter((name) => name.toLowerCase().includes(term.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
|
||||
if (localResults.length > 0) {
|
||||
setSearchResults(localResults);
|
||||
}
|
||||
|
||||
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'emojiSearch',
|
||||
searchTerm: term
|
||||
}));
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearch',
|
||||
searchTerm: term,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
@@ -63,22 +65,22 @@ export function EmojiSearch({
|
||||
const handleEmojiSearchResponse = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
|
||||
if (data.type === 'emojiSearchResponse') {
|
||||
const serverResults = data.results || [];
|
||||
|
||||
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter(name => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.filter((name) => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
|
||||
const combinedResults = [...serverResults];
|
||||
|
||||
localResults.forEach(name => {
|
||||
|
||||
localResults.forEach((name) => {
|
||||
if (!combinedResults.includes(name)) {
|
||||
combinedResults.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setSearchResults(combinedResults.slice(0, 10));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
@@ -95,18 +97,18 @@ export function EmojiSearch({
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!searchTerm || searchResults.length === 0) return;
|
||||
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev + 1) % searchResults.length);
|
||||
setSelectedIndex((prev) => (prev + 1) % searchResults.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
setSelectedIndex((prev) => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (searchResults[selectedIndex]) {
|
||||
@@ -127,10 +129,10 @@ export function EmojiSearch({
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
textarea.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
|
||||
return () => {
|
||||
textarea.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
@@ -150,25 +152,25 @@ export function EmojiSearch({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-16 left-4 bg-mantle border rounded-md shadow-lg max-h-60 overflow-y-auto z-10 min-w-[200px] max-w-[300px]">
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 mx-0 bg-mantle border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto z-10">
|
||||
<div ref={resultsRef} className="py-1">
|
||||
{searchResults.map((emojiName, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const emojiUrl = emojiMap.get(emojiName);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={emojiName}
|
||||
className={`px-3 py-1.5 flex items-center gap-2 cursor-pointer ${
|
||||
className={`px-3 py-2 flex items-center gap-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-primary/5'
|
||||
}`}
|
||||
onClick={() => onSelect(emojiName)}
|
||||
>
|
||||
{emojiUrl && (
|
||||
<Image src={emojiUrl} alt={emojiName} width={20} height={20} className="w-5 h-5" />
|
||||
<Image src={emojiUrl} alt={emojiName} width={24} height={24} className="w-6 h-6" />
|
||||
)}
|
||||
<span className="flex-grow text-sm">{emojiName}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-blue-500" />}
|
||||
<span className="flex-grow text-sm font-medium">{emojiName}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,28 +7,26 @@ import { Bot } from 'lucide-react';
|
||||
export function Message({ user, message, type, emojiMap }: MessageProps) {
|
||||
if (type === 'systemMsg') {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">{message}</span>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<span className="text-xs text-muted-foreground italic">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div lang="en" className="max-w-full break-all whitespace-pre-wrap hyphens-auto">
|
||||
<p className="flex flex-wrap items-center">
|
||||
<span className="font-bold mr-2 flex items-center">
|
||||
{user?.isBot && (
|
||||
<span className="text-xs text-muted-foreground flex mr-1">
|
||||
{' '}
|
||||
<Bot className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
{user?.displayName || user?.username}
|
||||
</span>
|
||||
|
||||
<div className="group hover:bg-primary/5 rounded px-2 py-1 -mx-2 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-semibold text-primary shrink-0 flex items-center gap-1">
|
||||
{user?.isBot && <Bot className="size-4 text-muted-foreground" />}
|
||||
<span>{user?.displayName || user?.username}</span>
|
||||
</span>
|
||||
<span
|
||||
lang="en"
|
||||
className="text-foreground break-words overflow-wrap-anywhere min-w-0 flex-1"
|
||||
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
|
||||
>
|
||||
<EmojiRenderer text={message} emojiMap={emojiMap} />
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -50,12 +48,8 @@ export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
|
||||
return (
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip delayDuration={250}>
|
||||
<TooltipTrigger>
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block align-middle"
|
||||
style={{ height: '1.2em' }}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center align-middle mx-0.5">
|
||||
<Image
|
||||
src={emojiUrl}
|
||||
alt={part}
|
||||
@@ -72,7 +66,11 @@ export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={index}>{part}</span>;
|
||||
// Preserve text as-is, handling whitespace properly
|
||||
if (part) {
|
||||
return <span key={index}>{part}</span>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,22 +8,16 @@ export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex-1">
|
||||
{/* Hero Section */}
|
||||
<section className="relative w-full py-20 md:py-32 lg:py-40 xl:py-48 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-secondary/10"></div>
|
||||
<div className="absolute inset-0"></div>
|
||||
<div className="container px-4 md:px-6 relative">
|
||||
<div className="flex flex-col items-center space-y-8 text-center">
|
||||
<Badge variant="outline" className="px-4 py-2 text-sm font-medium bg-primary/10 text-primary border-primary/20">
|
||||
<Heart className="w-4 h-4 mr-2" />
|
||||
Made with ❤️ by Hack Clubbers
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center space-y-8 text-center">
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl bg-gradient-to-r from-primary via-primary/80 to-primary/60 bg-clip-text text-transparent">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl text-primary">
|
||||
hackclub.tv
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[600px] text-lg text-muted-foreground md:text-xl">
|
||||
The streaming platform where Hack Clubbers share their coding journeys, workshops, and hackathon adventures with the world.
|
||||
The streaming website for Hack Clubbers, by Hack Clubbers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -42,133 +36,6 @@ export default function LandingPage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="w-full py-16 md:py-24 lg:py-32 bg-muted/30" id="features">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center text-center space-y-8 mb-16">
|
||||
<Badge variant="secondary" className="px-4 py-2">
|
||||
Platform Features
|
||||
</Badge>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
|
||||
Built for creators, by creators
|
||||
</h2>
|
||||
<p className="max-w-[700px] text-lg text-muted-foreground">
|
||||
Everything you need to connect, create, and grow within the Hack Club community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto">
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Zap className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Low-Latency Streaming</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Share your coding sessions with ultra-low latency. Your audience stays engaged with real-time interaction.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<MessageCircle className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Real-Time Chat</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Engage with your community through integrated chat. Get instant feedback and build connections.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Users className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Community First</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Follow your favorite streamers, discover new creators, and be part of the vibrant Hack Club ecosystem.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-accent/50 rounded-lg flex items-center justify-center mb-4">
|
||||
<Code className="w-6 h-6 text-accent-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Code-Focused</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Built specifically for developers. Perfect for coding sessions, tutorials, and technical workshops.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-secondary/50 rounded-lg flex items-center justify-center mb-4">
|
||||
<Play className="w-6 h-6 text-secondary-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Easy to Use</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Simple, intuitive interface. Start streaming in minutes, not hours. Focus on what you love: coding.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<Heart className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Open Source</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
Transparent, community-driven, and built in the open. Contribute, customize, and make it yours.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="w-full py-16 md:py-24">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center text-center space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Ready to share your journey?
|
||||
</h2>
|
||||
<p className="max-w-[600px] text-lg text-muted-foreground">
|
||||
Join the community of makers, builders, and dreamers. Start streaming your coding adventures today.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/login">
|
||||
<Button size="lg" className="px-8 py-3 text-lg font-semibold">
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
Get Started Now
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import StreamPlayer from '../StreamPlayer/StreamPlayer';
|
||||
import UserInfoCard from '../UserInfoCard/UserInfoCard';
|
||||
import ChatPanel from '../ChatPanel/ChatPanel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { StreamInfo, Channel } from '@hctv/db';
|
||||
import { useIsMobile } from '@/lib/hooks/useMobile';
|
||||
import { useAllChannels } from '@/lib/hooks/useUserList';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
export default function LiveStream(props: Props) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { channels, refresh } = useAllChannels(5000);
|
||||
const [isRestricted, setIsRestricted] = useState(false);
|
||||
const [restrictionExpiresAt, setRestrictionExpiresAt] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentStream = channels.find((s) => s.username === props.username);
|
||||
if (currentStream?.channel?.isRestricted) {
|
||||
setIsRestricted(true);
|
||||
setRestrictionExpiresAt(currentStream.channel.restrictionExpiresAt || null);
|
||||
} else if (isRestricted && currentStream && !currentStream.channel?.isRestricted) {
|
||||
setIsRestricted(false);
|
||||
setRestrictionExpiresAt(null);
|
||||
}
|
||||
}, [channels, props.username, isRestricted]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[calc(100vh-64px)] p-4">
|
||||
<h1 className="text-2xl font-bold text-destructive mb-2">Channel Restricted</h1>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-4">
|
||||
This channel has been restricted by a moderator and is no longer available for viewing.
|
||||
</p>
|
||||
{restrictionExpiresAt && (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Restriction lifts: {format(new Date(restrictionExpiresAt), 'PPP p')}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Checking...' : 'Check again'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full`}>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<StreamPlayer />
|
||||
{isMobile && (
|
||||
<div className="h-[300px]">
|
||||
<div className="flex-1 min-h-[250px] max-h-[400px] border-t border-border">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
)}
|
||||
<UserInfoCard streamInfo={props.streamInfo} />
|
||||
</div>
|
||||
|
||||
|
||||
{!isMobile && (
|
||||
<div>
|
||||
<div className="h-full shrink-0">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
)}
|
||||
@@ -33,4 +82,4 @@ export default function LiveStream(props: Props) {
|
||||
interface Props {
|
||||
username: string;
|
||||
streamInfo: StreamInfo & { channel: Channel };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { logout } from '@/lib/auth/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import Link from 'next/link';
|
||||
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
|
||||
import { IdCard, Slack } from 'lucide-react';
|
||||
import { IdCard, Shield } from 'lucide-react';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
export default function Navbar(props: Props) {
|
||||
@@ -57,6 +57,17 @@ export default function Navbar(props: Props) {
|
||||
<Link href={`/settings/bot`}>
|
||||
<DropdownMenuItem className="cursor-pointer">Bot accounts</DropdownMenuItem>
|
||||
</Link>
|
||||
{user.isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<Link href={`/admin`}>
|
||||
<DropdownMenuItem className="cursor-pointer text-primary">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Admin Panel
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<Link href={'https://docs.hackclub.tv'} target="_blank" rel="noreferrer">
|
||||
<DropdownMenuItem className="cursor-pointer">API Docs</DropdownMenuItem>
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
Sidebar as UISidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { StreamInfoResponse, useStreams } from '@/lib/providers/StreamInfoProvider';
|
||||
import { StreamInfoResponse } from '@/lib/providers/StreamInfoProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAllChannels } from '@/lib/hooks/useUserList';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export default function Sidebar({ ...props }: React.ComponentProps<typeof UISidebar>) {
|
||||
const { channels: stream, isLoading } = useAllChannels(5000);
|
||||
const [followedExpanded, setFollowedExpanded] = React.useState(true);
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === 'collapsed';
|
||||
|
||||
@@ -95,10 +89,13 @@ function StreamerItem({ streamer, isCollapsed }: { streamer: StreamInfoResponse[
|
||||
>
|
||||
<button className="flex w-full items-center gap-3">
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={streamer.channel.pfpUrl} alt={streamer.username} className="object-cover" />
|
||||
<AvatarFallback>{streamer.username[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={streamer.channel.pfpUrl}
|
||||
alt={streamer.username}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{streamer.isLive && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background ring-2 ring-background">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
||||
|
||||
@@ -7,18 +7,20 @@ import {
|
||||
MediaLoadingIndicator,
|
||||
MediaControlBar,
|
||||
MediaPlayButton,
|
||||
MediaSeekBackwardButton,
|
||||
MediaSeekForwardButton,
|
||||
MediaMuteButton,
|
||||
MediaVolumeRange,
|
||||
MediaFullscreenButton,
|
||||
} from 'media-chrome/react';
|
||||
import HlsVideo from 'hls-video-element/react';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import { useUserStreamInfo } from '@/lib/hooks/useUserList';
|
||||
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
||||
|
||||
export default function StreamPlayer() {
|
||||
const { username } = useParams();
|
||||
const { session } = useSession();
|
||||
const { streamInfo: userInfo } = useUserStreamInfo(username!.toString());
|
||||
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,10 +36,15 @@ export default function StreamPlayer() {
|
||||
},
|
||||
lowLatencyMode: true,
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
backBufferLength: 90,
|
||||
enableWorker: true,
|
||||
maxLiveSyncPlaybackRate: 1.5,
|
||||
liveSyncDurationCount: 2,
|
||||
liveMaxLatencyDurationCount: 4,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
video.src = `${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${username}/index.m3u8`;
|
||||
video.src = `${getMediamtxClientEnvs(userInfo?.streamRegion!).publicUrl}/${username}/index.m3u8`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -21,7 +21,12 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
createBotSchema,
|
||||
createChannelSchema, editBotSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema
|
||||
createChannelSchema,
|
||||
changeUsernameSchema,
|
||||
editBotSchema,
|
||||
onboardSchema,
|
||||
streamInfoEditSchema,
|
||||
updateChannelSettingsSchema,
|
||||
} from '@/lib/form/zod';
|
||||
|
||||
export const schemaDb = [
|
||||
@@ -30,7 +35,8 @@ export const schemaDb = [
|
||||
{ name: 'createChannel', zod: createChannelSchema },
|
||||
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
|
||||
{ name: 'createBot', zod: createBotSchema },
|
||||
{ name: 'editBot', zod: editBotSchema }
|
||||
{ name: 'editBot', zod: editBotSchema },
|
||||
{ name: 'changeUsername', zod: changeUsernameSchema },
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
@@ -62,7 +68,7 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
}, [fields, defaultValues]);
|
||||
|
||||
type FormData = z.infer<T>;
|
||||
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(schema as any),
|
||||
defaultValues: initialValues as FormData,
|
||||
@@ -86,8 +92,8 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
control={form.control}
|
||||
name={field.name as Path<FormData>}
|
||||
render={({ field: formField }) => (
|
||||
<FormItem>
|
||||
{(field.type !== 'hidden' || field.label) && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormItem className={field.type === 'hidden' ? 'hidden' : undefined}>
|
||||
{field.type !== 'hidden' && field.label && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormControl>
|
||||
{field.component ? (
|
||||
field.component({ field: formField, ...field.componentProps })
|
||||
@@ -97,27 +103,37 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
{...formField}
|
||||
value={formField.value ?? ''}
|
||||
rows={field.textAreaRows ?? 5}
|
||||
maxLength={field.maxChars}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type || 'text'}
|
||||
placeholder={field.placeholder}
|
||||
{...formField}
|
||||
onChange={(e) => {
|
||||
if (field.inputFilter) {
|
||||
e.target.value = e.target.value.replace(field.inputFilter, '');
|
||||
}
|
||||
formField.onChange(e);
|
||||
}}
|
||||
value={formField.value ?? ''}
|
||||
maxLength={field.maxChars}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{field.description && <FormDescription>{field.description}</FormDescription>}
|
||||
<FormMessage />
|
||||
{field.type !== 'hidden' && field.description && (
|
||||
<FormDescription>{field.description}</FormDescription>
|
||||
)}
|
||||
{field.type !== 'hidden' && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<div className={cn("flex gap-2 py-2", submitButtonDivClassname)}>
|
||||
<div className={cn('flex gap-2 py-2', submitButtonDivClassname)}>
|
||||
{otherSubmitButton}
|
||||
<SubmitButton buttonText={submitText} className={submitClassname} />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export type FormFieldConfig = {
|
||||
value?: any;
|
||||
textArea?: boolean;
|
||||
textAreaRows?: number;
|
||||
maxChars?: number;
|
||||
inputFilter?: RegExp;
|
||||
component?: (props: { field: ControllerRenderProps<any, any> } & any) => React.ReactNode;
|
||||
componentProps?: Record<string, any>;
|
||||
required?: boolean;
|
||||
|
||||
213
apps/web/src/components/ui/calendar.tsx
Normal file
213
apps/web/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-[--cell-size] select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"bg-accent rounded-l-md",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
121
apps/web/src/lib/auth/abac.ts
Normal file
121
apps/web/src/lib/auth/abac.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { prisma } from '@hctv/db';
|
||||
|
||||
export type Resource = 'channel' | 'bot' | 'streamInfo';
|
||||
export type Action = 'read' | 'update' | 'delete' | 'manage';
|
||||
|
||||
type User = { id: string };
|
||||
|
||||
type ChannelWithRelations = {
|
||||
ownerId: string;
|
||||
managers?: { id: string }[];
|
||||
personalFor?: { id: string } | null;
|
||||
};
|
||||
|
||||
type BotWithRelations = {
|
||||
ownerId: string;
|
||||
};
|
||||
|
||||
type PolicyContext = {
|
||||
channel?: ChannelWithRelations;
|
||||
bot?: BotWithRelations;
|
||||
};
|
||||
|
||||
const policies: Record<Resource, Record<Action, (user: User, ctx: PolicyContext) => boolean>> = {
|
||||
channel: {
|
||||
read: () => true,
|
||||
update: (user, { channel }) => {
|
||||
if (!channel) return false;
|
||||
return channel.ownerId === user.id || (channel.managers?.some((m) => m.id === user.id) ?? false);
|
||||
},
|
||||
delete: (user, { channel }) => {
|
||||
if (!channel) return false;
|
||||
if (channel.personalFor) return false;
|
||||
return channel.ownerId === user.id;
|
||||
},
|
||||
manage: (user, { channel }) => {
|
||||
if (!channel) return false;
|
||||
return channel.ownerId === user.id;
|
||||
},
|
||||
},
|
||||
bot: {
|
||||
read: () => true,
|
||||
update: (user, { bot }) => {
|
||||
if (!bot) return false;
|
||||
return bot.ownerId === user.id;
|
||||
},
|
||||
delete: (user, { bot }) => {
|
||||
if (!bot) return false;
|
||||
return bot.ownerId === user.id;
|
||||
},
|
||||
manage: (user, { bot }) => {
|
||||
if (!bot) return false;
|
||||
return bot.ownerId === user.id;
|
||||
},
|
||||
},
|
||||
streamInfo: {
|
||||
read: () => true,
|
||||
update: (user, { channel }) => {
|
||||
if (!channel) return false;
|
||||
return channel.ownerId === user.id || (channel.managers?.some((m) => m.id === user.id) ?? false);
|
||||
},
|
||||
delete: () => false,
|
||||
manage: (user, { channel }) => {
|
||||
if (!channel) return false;
|
||||
return channel.ownerId === user.id;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function can(user: User, action: Action, resource: Resource, context: PolicyContext): boolean {
|
||||
const policy = policies[resource]?.[action];
|
||||
if (!policy) return false;
|
||||
return policy(user, context);
|
||||
}
|
||||
|
||||
export async function canAccessChannel(
|
||||
user: User,
|
||||
action: Action,
|
||||
channelId: string
|
||||
): Promise<boolean> {
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { managers: { select: { id: true } }, personalFor: { select: { id: true } } },
|
||||
});
|
||||
if (!channel) return false;
|
||||
return can(user, action, 'channel', { channel });
|
||||
}
|
||||
|
||||
export async function canAccessChannelByName(
|
||||
user: User,
|
||||
action: Action,
|
||||
channelName: string
|
||||
): Promise<boolean> {
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { name: channelName },
|
||||
include: { managers: { select: { id: true } }, personalFor: { select: { id: true } } },
|
||||
});
|
||||
if (!channel) return false;
|
||||
return can(user, action, 'channel', { channel });
|
||||
}
|
||||
|
||||
export async function canAccessBot(user: User, action: Action, botId: string): Promise<boolean> {
|
||||
const bot = await prisma.botAccount.findUnique({
|
||||
where: { id: botId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
if (!bot) return false;
|
||||
return can(user, action, 'bot', { bot });
|
||||
}
|
||||
|
||||
export async function canAccessBotBySlug(
|
||||
user: User,
|
||||
action: Action,
|
||||
slug: string
|
||||
): Promise<boolean> {
|
||||
const bot = await prisma.botAccount.findUnique({
|
||||
where: { slug },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
if (!bot) return false;
|
||||
return can(user, action, 'bot', { bot });
|
||||
}
|
||||
@@ -12,5 +12,5 @@ export async function logout() {
|
||||
await lucia.invalidateSession(session!.id);
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect('/login');
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
10
apps/web/src/lib/env.ts
Normal file
10
apps/web/src/lib/env.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const getEnv = (key: string): string | undefined => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
return window.__ENV?.[key];
|
||||
}
|
||||
return process.env[key];
|
||||
};
|
||||
|
||||
export const MEDIAMTX_URL = getEnv('NEXT_PUBLIC_MEDIAMTX_URL');
|
||||
export const MEDIAMTX_INGEST_ROUTE = getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE');
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { prisma, getRedisConnection } from '@hctv/db';
|
||||
import zodVerify from '../zodVerify';
|
||||
import {
|
||||
createBotSchema,
|
||||
createChannelSchema,
|
||||
changeUsernameSchema,
|
||||
editBotSchema,
|
||||
onboardSchema,
|
||||
streamInfoEditSchema,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
resolveStreamInfo,
|
||||
resolveUserFromPersonalChannelName,
|
||||
} from '../auth/resolve';
|
||||
import { can } from '../auth/abac';
|
||||
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
|
||||
import { generateStreamKey } from '../db/streamKey';
|
||||
|
||||
@@ -42,9 +44,7 @@ export async function editStreamInfo(prev: any, formData: FormData) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
const isBroadcaster =
|
||||
channelInfo.ownerId === user.id || channelInfo.managers.some((m) => m.id === user.id);
|
||||
if (!isBroadcaster) {
|
||||
if (!can(user, 'update', 'streamInfo', { channel: channelInfo })) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ export async function onboard(prev: any, formData: FormData) {
|
||||
|
||||
await generateStreamKey(createdChannel.id, createdChannel.name);
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
await fetch(process.env.WELCOME_WORKFLOW_URL!, {
|
||||
if (process.env.NODE_ENV === 'production' && process.env.WELCOME_WORKFLOW_URL) {
|
||||
await fetch(process.env.WELCOME_WORKFLOW_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: zod.data.username,
|
||||
@@ -202,10 +202,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
const isOwner = channel.ownerId === user.id;
|
||||
const isManager = channel.managers.some((manager) => manager.id === user.id);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
if (!can(user, 'update', 'channel', { channel })) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
@@ -242,7 +239,7 @@ export async function addChannelManager(channelId: string, userChannel: string)
|
||||
return { success: false, error: 'Channel not found OR is personal.' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
if (!can(user, 'manage', 'channel', { channel })) {
|
||||
return { success: false, error: 'Only channel owners can add managers' };
|
||||
}
|
||||
|
||||
@@ -286,7 +283,7 @@ export async function removeChannelManager(channelId: string, userId: string) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
if (!can(user, 'manage', 'channel', { channel })) {
|
||||
return { success: false, error: 'Only channel owners can remove managers' };
|
||||
}
|
||||
|
||||
@@ -355,12 +352,11 @@ export async function deleteChannel(channelId: string) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Only channel owners can delete channels' };
|
||||
}
|
||||
|
||||
if (channel.personalFor) {
|
||||
return { success: false, error: 'Cannot delete personal channels' };
|
||||
if (!can(user, 'delete', 'channel', { channel })) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Only channel owners can delete channels (personal channels cannot be deleted)',
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.channel.delete({
|
||||
@@ -416,7 +412,7 @@ export async function editBot(prev: any, formData: FormData) {
|
||||
if (!bot) {
|
||||
return { success: false, error: 'Bot not found' };
|
||||
}
|
||||
if (bot.ownerId !== user.id) {
|
||||
if (!can(user, 'update', 'bot', { bot })) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
if (bot.slug !== zod.data.slug) {
|
||||
@@ -441,3 +437,125 @@ export async function editBot(prev: any, formData: FormData) {
|
||||
|
||||
return { success: true, slug: updatedBot.slug };
|
||||
}
|
||||
|
||||
const USERNAME_CHANGE_COOLDOWN_DAYS = 30;
|
||||
|
||||
export async function changeUsername(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const zod = await zodVerify(changeUsernameSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: zod.data.channelId },
|
||||
include: {
|
||||
owner: true,
|
||||
managers: true,
|
||||
personalFor: true,
|
||||
streamInfo: true,
|
||||
streamKey: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (!channel.personalFor || channel.personalFor.id !== user.id) {
|
||||
return { success: false, error: 'You can only change the username of your personal channel' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
if (channel.nameLastChanged) {
|
||||
const daysSinceLastChange = Math.floor(
|
||||
(Date.now() - new Date(channel.nameLastChanged).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
if (daysSinceLastChange < USERNAME_CHANGE_COOLDOWN_DAYS) {
|
||||
const daysRemaining = USERNAME_CHANGE_COOLDOWN_DAYS - daysSinceLastChange;
|
||||
return {
|
||||
success: false,
|
||||
error: `Please wait ${daysRemaining} more day${daysRemaining === 1 ? '' : 's'}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const oldName = channel.name;
|
||||
const newName = zod.data.newUsername;
|
||||
|
||||
if (oldName === newName) {
|
||||
return { success: false, error: 'New username must be different from the current one' };
|
||||
}
|
||||
|
||||
const existingChannel = await prisma.channel.findUnique({
|
||||
where: { name: newName },
|
||||
});
|
||||
if (existingChannel) {
|
||||
return { success: false, error: 'This username is already taken' };
|
||||
}
|
||||
|
||||
const redis = getRedisConnection();
|
||||
|
||||
try {
|
||||
await prisma.channel.update({
|
||||
where: { id: channel.id },
|
||||
data: {
|
||||
name: newName,
|
||||
nameLastChanged: process.env.NODE_ENV === 'production' ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (channel.streamInfo.length > 0) {
|
||||
await prisma.streamInfo.updateMany({
|
||||
where: { channelId: channel.id },
|
||||
data: { username: newName },
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.streamKey) {
|
||||
const oldStreamKey = `streamKey:${oldName}`;
|
||||
const newStreamKey = `streamKey:${newName}`;
|
||||
if (await redis.exists(oldStreamKey)) {
|
||||
await redis.rename(oldStreamKey, newStreamKey);
|
||||
}
|
||||
}
|
||||
|
||||
const oldHistoryKey = `chat:history:${oldName}`;
|
||||
const newHistoryKey = `chat:history:${newName}`;
|
||||
if (await redis.exists(oldHistoryKey)) {
|
||||
const messagesWithScores = await redis.zrange(oldHistoryKey, 0, -1, 'WITHSCORES');
|
||||
if (messagesWithScores.length > 0) {
|
||||
const args: (string | number)[] = [];
|
||||
for (let i = 0; i < messagesWithScores.length; i += 2) {
|
||||
const msgStr = messagesWithScores[i];
|
||||
const score = messagesWithScores[i + 1];
|
||||
try {
|
||||
const msg = JSON.parse(msgStr);
|
||||
msg.user.username = newName;
|
||||
args.push(score, JSON.stringify(msg));
|
||||
} catch {
|
||||
args.push(score, msgStr);
|
||||
}
|
||||
}
|
||||
await redis.zadd(newHistoryKey, ...args);
|
||||
}
|
||||
await redis.del(oldHistoryKey);
|
||||
}
|
||||
|
||||
revalidatePath(`/settings/channel/${newName}`);
|
||||
revalidatePath(`/${oldName}`);
|
||||
revalidatePath(`/${newName}`);
|
||||
|
||||
return { success: true, newUsername: newName };
|
||||
} catch (error) {
|
||||
console.error('Failed to change username:', error);
|
||||
return { success: false, error: 'Failed to change username. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const disallowedUsernames = [
|
||||
const username = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(20)
|
||||
.regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' })
|
||||
.refine((val) => !disallowedUsernames.includes(val.toLowerCase()), {
|
||||
message: 'This username is reserved',
|
||||
@@ -49,3 +50,8 @@ export const editBotSchema = createBotSchema.and(
|
||||
from: z.string().min(1),
|
||||
})
|
||||
);
|
||||
|
||||
export const changeUsernameSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
newUsername: username,
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ function createCacheKey(options: UseUserListOptions): string {
|
||||
if (options.owned) params.push('owned')
|
||||
if (options.personal) params.push('personal')
|
||||
if (options.live) params.push('live')
|
||||
if (options.username) params.push(`user-${options.username}`)
|
||||
|
||||
return params.length > 0
|
||||
? `stream-info:${params.join('-')}`
|
||||
@@ -76,6 +77,8 @@ export interface UseUserListOptions {
|
||||
personal?: boolean
|
||||
/** Only fetch live channels */
|
||||
live?: boolean
|
||||
/** Search for a specific user's streaminfo */
|
||||
username?: string
|
||||
/** Refresh interval in milliseconds */
|
||||
refreshInterval?: number
|
||||
/** Cache time to live in milliseconds (default: 5 minutes) */
|
||||
@@ -132,6 +135,7 @@ export function useUserList(options: UseUserListOptions = {}): UseUserListReturn
|
||||
owned = false,
|
||||
personal = false,
|
||||
live = false,
|
||||
username,
|
||||
refreshInterval = 30000,
|
||||
cacheTTL = 5 * 60 * 1000, // 5 minutes
|
||||
revalidateOnFocus = false,
|
||||
@@ -151,8 +155,9 @@ export function useUserList(options: UseUserListOptions = {}): UseUserListReturn
|
||||
if (owned) searchParams.set('owned', 'true')
|
||||
if (personal) searchParams.set('personal', 'true')
|
||||
if (live) searchParams.set('live', 'true')
|
||||
if (username) searchParams.set('username', username)
|
||||
return searchParams
|
||||
}, [owned, personal, live])
|
||||
}, [owned, personal, live, username])
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `/api/stream/info${queryString ? `?${queryString}` : ''}`
|
||||
@@ -325,6 +330,67 @@ export function usePersonalChannels(refreshInterval?: number): UseUserListReturn
|
||||
})
|
||||
}
|
||||
|
||||
export interface UseUserStreamInfoReturn extends Omit<UseUserListReturn, 'channels'> {
|
||||
/** The found stream info for the specific user */
|
||||
streamInfo: StreamInfoResponse[0] | null
|
||||
/** All matching channels (usually just one) */
|
||||
channels: StreamInfoResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch stream info for a specific user
|
||||
* Returns the first match if multiple channels exist for that user
|
||||
*/
|
||||
export function useUserStreamInfo(
|
||||
username: string | undefined,
|
||||
refresh = true,
|
||||
refreshInterval?: number,
|
||||
): UseUserStreamInfoReturn {
|
||||
const result = useUserList({
|
||||
username,
|
||||
refreshInterval: refresh ? (refreshInterval ?? 15000) : undefined,
|
||||
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
||||
revalidateOnFocus: true,
|
||||
isPaused: !username, // Don't fetch if no username provided
|
||||
errorRetryCount: 3,
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
streamInfo: result.channels[0] || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy version that doesn't automatically fetch - useful for on-demand lookups
|
||||
*/
|
||||
export function useUserStreamInfoLazy(refreshInterval?: number) {
|
||||
const result = useUserList({
|
||||
refreshInterval: refreshInterval ?? 15000,
|
||||
cacheTTL: 2 * 60 * 1000,
|
||||
revalidateOnFocus: true,
|
||||
isPaused: true, // Start paused
|
||||
errorRetryCount: 3,
|
||||
})
|
||||
|
||||
const lookupUser = useCallback(async (username: string) => {
|
||||
if (!username) return null
|
||||
|
||||
try {
|
||||
const response = await enhancedFetcher(`/api/stream/info?username=${encodeURIComponent(username)}`)
|
||||
return response[0] || null
|
||||
} catch (error) {
|
||||
console.error('[useUserStreamInfoLazy] Error looking up user:', error)
|
||||
throw error
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...result,
|
||||
lookupUser,
|
||||
}
|
||||
}
|
||||
|
||||
// Cache management utilities with proper error handling
|
||||
export const channelCacheUtils = {
|
||||
/** Clear all channel caches */
|
||||
@@ -379,6 +445,7 @@ export const channelCacheUtils = {
|
||||
if (options.owned) params.set('owned', 'true')
|
||||
if (options.personal) params.set('personal', 'true')
|
||||
if (options.live) params.set('live', 'true')
|
||||
if (options.username) params.set('username', options.username)
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `/api/stream/info${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
@@ -14,8 +14,10 @@ export default async function getLiveThumb() {
|
||||
|
||||
const thumbQueue = getThumbnailQueue();
|
||||
for (const channel of liveChannelNames) {
|
||||
const lc = liveChannels.find(c => c.channel.name === channel)!;
|
||||
await thumbQueue.add("getLiveThumb", {
|
||||
name: channel,
|
||||
server: lc.streamRegion,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { HttpFlv } from '../types/liveBackendJson';
|
||||
import { getNotificationQueue } from '../workers';
|
||||
import client from '../services/slackNotifier';
|
||||
import type { paths } from '../types/mediamtx.d.ts';
|
||||
import { MEDIAMTX_SERVER_REGIONS } from '../utils/mediamtx/server';
|
||||
|
||||
export default async function runner() {
|
||||
// if there are no users it explodes so yeah
|
||||
@@ -49,37 +50,43 @@ export async function initializeStreamInfo(channelId?: string) {
|
||||
|
||||
export async function syncStream() {
|
||||
try {
|
||||
const response = await fetch(`${process.env.MEDIAMTX_API}/v3/paths/list?itemsPerPage=1000`);
|
||||
const regions = Object.keys(MEDIAMTX_SERVER_REGIONS) as Array<
|
||||
keyof typeof MEDIAMTX_SERVER_REGIONS
|
||||
>;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch stream stats: ${response.status} ${response.statusText}`);
|
||||
return;
|
||||
const allActiveStreams = new Map<string, keyof typeof MEDIAMTX_SERVER_REGIONS>();
|
||||
|
||||
for (const r of regions) {
|
||||
const region = MEDIAMTX_SERVER_REGIONS[r];
|
||||
const response = await fetch(`${region.apiUrl}/v3/paths/list?itemsPerPage=1000`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`Failed to fetch ${r} stream stats: ${response.status} ${response.statusText}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
type ResponseType =
|
||||
paths['/v3/paths/list']['get']['responses']['200']['content']['application/json'];
|
||||
const data = (await response.json()) as ResponseType;
|
||||
|
||||
if (data?.items) {
|
||||
for (const stream of data.items) {
|
||||
if (stream.ready && stream.name) {
|
||||
allActiveStreams.set(stream.name, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ResponseType = paths['/v3/paths/list']['get']['responses']['200']['content']['application/json'];
|
||||
const data = await response.json() as ResponseType;
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeStreams = data.items!;
|
||||
|
||||
// handle streams going offline
|
||||
const currentLiveStreams = await prisma.streamInfo.findMany({
|
||||
where: { isLive: true },
|
||||
});
|
||||
|
||||
const activeStreamMap = new Map();
|
||||
for (const stream of activeStreams) {
|
||||
activeStreamMap.set(stream.name, {
|
||||
isLive: stream.ready,
|
||||
});
|
||||
}
|
||||
|
||||
for (const dbStream of currentLiveStreams) {
|
||||
const streamStats = activeStreamMap.get(dbStream.username);
|
||||
|
||||
if (!streamStats || !streamStats.isLive) {
|
||||
if (!allActiveStreams.has(dbStream.username)) {
|
||||
await prisma.streamInfo.update({
|
||||
where: { username: dbStream.username },
|
||||
data: {
|
||||
@@ -91,50 +98,52 @@ export async function syncStream() {
|
||||
}
|
||||
}
|
||||
|
||||
for (const stream of activeStreams) {
|
||||
if (stream.ready) {
|
||||
const existingStream = await prisma.streamInfo.findUnique({
|
||||
where: { username: stream.name },
|
||||
include: { channel: true },
|
||||
// handle streams going online
|
||||
for (const [username, regionKey] of allActiveStreams) {
|
||||
const existingStream = await prisma.streamInfo.findUnique({
|
||||
where: { username },
|
||||
include: { channel: true },
|
||||
});
|
||||
|
||||
if (existingStream && !existingStream.isLive) {
|
||||
console.log(`Stream ${username} is now live in region ${regionKey}`);
|
||||
await prisma.streamInfo.update({
|
||||
where: { username },
|
||||
data: {
|
||||
isLive: true,
|
||||
startedAt: new Date(),
|
||||
streamRegion: regionKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingStream && !existingStream.isLive) {
|
||||
await prisma.streamInfo.update({
|
||||
where: { username: stream.name },
|
||||
data: {
|
||||
isLive: true,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const subscribedFollowers = await prisma.follow.findMany({
|
||||
where: {
|
||||
channelId: existingStream.channelId,
|
||||
notifyStream: true,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
const subscribedFollowers = await prisma.follow.findMany({
|
||||
where: {
|
||||
channelId: existingStream.channelId,
|
||||
notifyStream: true,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
const queue = getNotificationQueue();
|
||||
const queue = getNotificationQueue();
|
||||
|
||||
if (!existingStream.channel.is247) {
|
||||
queue.add(`streamStartChannel:${existingStream.username}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>`,
|
||||
channel: process.env.NOTIFICATION_CHANNEL_ID!,
|
||||
if (!existingStream.channel.is247) {
|
||||
queue.add(`streamStartChannel:${existingStream.username}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>`,
|
||||
channel: process.env.NOTIFICATION_CHANNEL_ID!,
|
||||
unfurl_links: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (existingStream.enableNotifications && !existingStream.channel.is247) {
|
||||
for (const follower of subscribedFollowers) {
|
||||
queue.add(`streamStartDm:${follower.user.id}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
|
||||
channel: follower.user.slack_id,
|
||||
unfurl_links: true,
|
||||
});
|
||||
}
|
||||
if (existingStream.enableNotifications && !existingStream.channel.is247) {
|
||||
for (const follower of subscribedFollowers) {
|
||||
queue.add(`streamStartDm:${follower.user.id}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
|
||||
channel: follower.user.slack_id,
|
||||
unfurl_links: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,4 +37,9 @@ export function useStreams() {
|
||||
return context
|
||||
}
|
||||
|
||||
export type StreamInfoResponse = (StreamInfo & { channel: Channel })[]
|
||||
export type StreamInfoResponse = (StreamInfo & {
|
||||
channel: Channel & {
|
||||
isRestricted?: boolean;
|
||||
restrictionExpiresAt?: string | null;
|
||||
};
|
||||
})[]
|
||||
29
apps/web/src/lib/utils/mediamtx/client.ts
Normal file
29
apps/web/src/lib/utils/mediamtx/client.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { MediaMTXRegion } from './regions';
|
||||
import { getEnv } from '@/lib/env';
|
||||
|
||||
export interface MediaMTXClientEnvs {
|
||||
publicUrl: string;
|
||||
ingestRoute: string;
|
||||
emoji: string;
|
||||
string: string;
|
||||
}
|
||||
|
||||
export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXClientEnvs {
|
||||
const envs: Record<MediaMTXRegion, MediaMTXClientEnvs> = {
|
||||
hq: {
|
||||
publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_HQ')!,
|
||||
ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ')!,
|
||||
emoji: '🇺🇸',
|
||||
string: 'HQ Server A',
|
||||
},
|
||||
};
|
||||
|
||||
const regionEnvs = envs[region];
|
||||
|
||||
if (!regionEnvs) {
|
||||
throw new Error(`Invalid MediaMTX region: ${region}`);
|
||||
}
|
||||
|
||||
return regionEnvs;
|
||||
}
|
||||
|
||||
1
apps/web/src/lib/utils/mediamtx/regions.ts
Normal file
1
apps/web/src/lib/utils/mediamtx/regions.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type MediaMTXRegion = 'hq';
|
||||
21
apps/web/src/lib/utils/mediamtx/server.ts
Normal file
21
apps/web/src/lib/utils/mediamtx/server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { MediaMTXRegion } from './regions';
|
||||
|
||||
export interface MediaMTXEnvs {
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export const MEDIAMTX_SERVER_REGIONS: Record<MediaMTXRegion, MediaMTXEnvs> = {
|
||||
hq: {
|
||||
apiUrl: process.env.MEDIAMTX_API_HQ!,
|
||||
},
|
||||
};
|
||||
|
||||
export function getMediamtxEnvs(region: MediaMTXRegion = 'hq'): MediaMTXEnvs {
|
||||
const envs = MEDIAMTX_SERVER_REGIONS[region];
|
||||
|
||||
if (!envs) {
|
||||
throw new Error(`Invalid MediaMTX region: ${region}`);
|
||||
}
|
||||
|
||||
return envs;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getRedisConnection } from '@hctv/db';
|
||||
import { promisify } from 'node:util';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { exec as execCallback } from 'node:child_process';
|
||||
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
|
||||
const pExec = promisify(execCallback);
|
||||
|
||||
const globalForWorker = global as unknown as {
|
||||
@@ -26,7 +27,10 @@ export async function registerThumbnailWorker(): Promise<void> {
|
||||
try {
|
||||
// this is totally unnecessary, but i'll keep it for security purposes.
|
||||
const name = job.data.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const m3u8location = `${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${name}/index.m3u8`;
|
||||
const server = job.data.server || 'hq';
|
||||
const srvValues = getMediamtxClientEnvs(server);
|
||||
|
||||
const m3u8location = `${srvValues.publicUrl}/${name}/index.m3u8`;
|
||||
const thumbDir = '/dev/shm/hctv-thumb';
|
||||
|
||||
if (!existsSync(thumbDir)) {
|
||||
@@ -41,11 +45,11 @@ export async function registerThumbnailWorker(): Promise<void> {
|
||||
);
|
||||
return { success: true };
|
||||
} catch (ffmpegError) {
|
||||
console.error(`FFmpeg error for ${name}:`, ffmpegError);
|
||||
console.error(`FFmpeg error for ${name} on server ${server}:`, ffmpegError);
|
||||
return { success: false, error: ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Slack notification failed:', e);
|
||||
console.error('Thumbnail generation failed:', e);
|
||||
// @ts-ignore e is unknown
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
|
||||
60
compose.yml
Normal file
60
compose.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
services:
|
||||
hctv:
|
||||
container_name: hctv
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
pgbouncer:
|
||||
condition: service_started
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
image: srizan10/hclive
|
||||
chat:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
hctv:
|
||||
condition: service_started
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
image: srizan10/hclive-chat
|
||||
postgres:
|
||||
image: 'postgres:17-alpine'
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: '${PG_PASS}'
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- 'hctv_pgdata:/var/lib/postgresql/data'
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- 'pg_isready -U postgres'
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
pgbouncer:
|
||||
image: 'bitnamilegacy/pgbouncer:1'
|
||||
environment:
|
||||
- POSTGRESQL_HOST=postgres
|
||||
- POSTGRESQL_PORT=5432
|
||||
- POSTGRESQL_USERNAME=postgres
|
||||
- 'POSTGRESQL_PASSWORD=${PG_PASS}'
|
||||
- PGBOUNCER_POOL_MODE=transaction
|
||||
- PGBOUNCER_MAX_CLIENT_CONN=100
|
||||
- PGBOUNCER_DEFAULT_POOL_SIZE=20
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
redis:
|
||||
image: 'redis:7.4-alpine'
|
||||
volumes:
|
||||
- 'hctv_redis:/data'
|
||||
mediamtx:
|
||||
image: 'bluenviron/mediamtx:latest'
|
||||
ports:
|
||||
- '8890:8890/udp'
|
||||
volumes:
|
||||
- './mediamtx.yml:/mediamtx.yml'
|
||||
@@ -1,12 +1,12 @@
|
||||
services:
|
||||
psql:
|
||||
image: postgres
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
# my condolences
|
||||
POSTGRES_PASSWORD: skbiditoilet
|
||||
volumes:
|
||||
- ./psql:/var/lib/postgresql/data
|
||||
- ./psql:/var/lib/postgresql
|
||||
ports:
|
||||
- 5555:5432
|
||||
redis:
|
||||
@@ -22,4 +22,16 @@ services:
|
||||
- 8891:8888
|
||||
- 9997:9997
|
||||
volumes:
|
||||
- ./mediamtx.yml:/mediamtx.yml
|
||||
- ./mediamtx.yml:/mediamtx.yml
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# mediamtx2:
|
||||
# image: bluenviron/mediamtx:latest
|
||||
# ports:
|
||||
# - 8990:8890/udp
|
||||
# - 8991:8891
|
||||
# - 9999:9997
|
||||
# volumes:
|
||||
# - ./mediamtx.yml:/mediamtx.yml
|
||||
# extra_hosts:
|
||||
# - "host.docker.internal:host-gateway"
|
||||
@@ -6,8 +6,13 @@ srt: yes
|
||||
srtAddress: :8890
|
||||
|
||||
hls: yes
|
||||
hlsAddress: :8891
|
||||
hlsSegmentCount: 7
|
||||
hlsSegmentDuration: 500ms
|
||||
hlsPartDuration: 200ms
|
||||
hlsMuxerCloseAfter: 5s
|
||||
|
||||
authMethod: http
|
||||
authHTTPAddress: http://192.168.1.47:3000/api/mediamtx/publish
|
||||
authHTTPAddress: http://host.docker.internal:3000/api/mediamtx/publish
|
||||
|
||||
api: yes
|
||||
api: yes
|
||||
|
||||
12
package.json
12
package.json
@@ -14,14 +14,16 @@
|
||||
"docker:web": "dotenvx run -f .env.docker -- docker buildx build --platform linux/amd64 -f apps/web/Dockerfile . --secret id=TURBO_TOKEN,env=TURBO_TOKEN --secret id=TURBO_TEAM,env=TURBO_TEAM --no-cache",
|
||||
"docker:chat": "dotenvx run -f .env.docker -- docker buildx build --platform linux/amd64 -f apps/chat/Dockerfile . --secret id=TURBO_TOKEN,env=TURBO_TOKEN --secret id=TURBO_TEAM,env=TURBO_TEAM --no-cache",
|
||||
"act": "act --secret-file .env.ci",
|
||||
"db:migrate": "yarn workspace @hctv/db db:migrate",
|
||||
"ui:add": "yarn workspace @hctv/web ui:add",
|
||||
"prisma": "yarn workspace @hctv/db prisma",
|
||||
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0"
|
||||
"db:migrate": "pnpm --filter=@hctv/db db:migrate",
|
||||
"ui:add": "pnpm --filter=@hctv/web ui:add",
|
||||
"prisma": "pnpm --filter=@hctv/db prisma",
|
||||
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0",
|
||||
"sdk:test": "dotenvx run -f .env.sdk -- pnpm --filter=@hctv/sdk test",
|
||||
"sdk:example": "dotenvx run -f .env.sdk -- pnpm --filter=@hctv/sdk example"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.6.2",
|
||||
"turbo": "^2.4.4"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
"packageManager": "pnpm@10.6.5"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"dev": "tsc --watch --preserveWatchOutput"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/db": "*",
|
||||
"@hctv/db": "workspace:*",
|
||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||
"arctic": "^3.1.1",
|
||||
"lucia": "^3.2.2"
|
||||
|
||||
@@ -10,9 +10,9 @@ export const hackClub = new OAuth2Client(
|
||||
process.env.HCID_REDIRECT_URI!
|
||||
);
|
||||
|
||||
export const HCID_AUTH_URL = "https://account.hackclub.com/oauth/authorize";
|
||||
export const HCID_TOKEN_URL = "https://account.hackclub.com/oauth/token";
|
||||
export const HCID_USER_INFO_URL = "https://account.hackclub.com/api/v1/me";
|
||||
export const HCID_AUTH_URL = "https://auth.hackclub.com/oauth/authorize";
|
||||
export const HCID_TOKEN_URL = "https://auth.hackclub.com/oauth/token";
|
||||
export const HCID_USER_INFO_URL = "https://auth.hackclub.com/api/v1/me";
|
||||
|
||||
export const lucia = new Lucia(adapter, {
|
||||
sessionCookie: {
|
||||
@@ -31,6 +31,7 @@ export const lucia = new Lucia(adapter, {
|
||||
pfpUrl: attributes.pfpUrl,
|
||||
hasOnboarded: attributes.hasOnboarded,
|
||||
personalChannelId: attributes.personalChannelId,
|
||||
isAdmin: attributes.isAdmin,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -48,4 +49,5 @@ interface DatabaseUserAttributes {
|
||||
pfpUrl: string;
|
||||
hasOnboarded: boolean;
|
||||
personalChannelId: string | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
2
packages/db/.env.example
Normal file
2
packages/db/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=postgresql://postgres:skbiditoilet@localhost:5555/postgres
|
||||
DATABASE_DIRECT_URL=postgresql://postgres:skbiditoilet@localhost:5555/postgres
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserBan" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"bannedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "UserBan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChannelRestriction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"restrictedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ChannelRestriction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserBan_userId_key" ON "UserBan"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserBan_userId_idx" ON "UserBan"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ChannelRestriction_channelId_key" ON "ChannelRestriction"("channelId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChannelRestriction_channelId_idx" ON "ChannelRestriction"("channelId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserBan" ADD CONSTRAINT "UserBan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChannelRestriction" ADD CONSTRAINT "ChannelRestriction_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "StreamInfo" ADD COLUMN "streamRegion" TEXT NOT NULL DEFAULT 'eu';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Channel" ADD COLUMN "nameLastChanged" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "StreamInfo" ALTER COLUMN "streamRegion" SET DEFAULT 'hq';
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE "StreamInfo" SET "streamRegion" = 'hq' WHERE "streamRegion" = 'eu';
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
provider = "postgresql"
|
||||
|
||||
@@ -17,11 +17,13 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
slack_id String
|
||||
email String?
|
||||
pfpUrl String
|
||||
id String @id @default(cuid())
|
||||
slack_id String
|
||||
email String?
|
||||
pfpUrl String
|
||||
|
||||
hasOnboarded Boolean @default(false)
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
personalChannel Channel? @relation("PersonalChannel", fields: [personalChannelId], references: [id])
|
||||
personalChannelId String? @unique
|
||||
@@ -32,6 +34,7 @@ model User {
|
||||
streams StreamInfo[]
|
||||
followers Follow[] @relation("UserFollows")
|
||||
botAccounts BotAccount[]
|
||||
ban UserBan?
|
||||
|
||||
@@index([personalChannelId])
|
||||
}
|
||||
@@ -42,8 +45,9 @@ model Channel {
|
||||
description String @default("A hctv channel")
|
||||
pfpUrl String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
nameLastChanged DateTime?
|
||||
|
||||
personalFor User? @relation("PersonalChannel")
|
||||
|
||||
@@ -55,6 +59,7 @@ model Channel {
|
||||
streamKey StreamKey?
|
||||
obsChatGrantToken String @unique @default(cuid())
|
||||
is247 Boolean @default(false)
|
||||
restriction ChannelRestriction?
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
@@ -75,6 +80,7 @@ model StreamInfo {
|
||||
category String
|
||||
startedAt DateTime
|
||||
isLive Boolean
|
||||
streamRegion String @default("hq")
|
||||
|
||||
channelId String
|
||||
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
@@ -113,12 +119,12 @@ model StreamKey {
|
||||
}
|
||||
|
||||
model BotAccount {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
displayName String
|
||||
slug String @unique
|
||||
description String @default("A hctv bot account")
|
||||
slug String @unique
|
||||
description String @default("A hctv bot account")
|
||||
pfpUrl String
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
ownerId String
|
||||
apiKeys BotApiKey[]
|
||||
|
||||
@@ -139,3 +145,27 @@ model BotApiKey {
|
||||
|
||||
@@index([botAccountId])
|
||||
}
|
||||
|
||||
model UserBan {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @unique
|
||||
reason String
|
||||
bannedBy String
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ChannelRestriction {
|
||||
id String @id @default(cuid())
|
||||
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
channelId String @unique
|
||||
reason String
|
||||
restrictedBy String
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
@@index([channelId])
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.17.0",
|
||||
"@hctv/db": "*"
|
||||
"@hctv/db": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hono/node-server": "^1.11.1",
|
||||
|
||||
7
packages/sdk/.eslint.cjs
Normal file
7
packages/sdk/.eslint.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
root: true,
|
||||
};
|
||||
130
packages/sdk/.gitignore
vendored
Normal file
130
packages/sdk/.gitignore
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
21
packages/sdk/LICENSE
Normal file
21
packages/sdk/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 SrIzan10
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
10
packages/sdk/README.md
Normal file
10
packages/sdk/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# @hctv/sdk
|
||||
|
||||
sdk for hctv, the live streaming platform for hack club.
|
||||
|
||||
check https://docs.hackclub.tv for sdk api documentation.
|
||||
check the /examples directory for example usage of the sdk.
|
||||
|
||||
## author
|
||||
|
||||
gpl3 by @srizan10 and claude
|
||||
39
packages/sdk/examples/ai.ts
Normal file
39
packages/sdk/examples/ai.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { HctvSdk } from "../src";
|
||||
|
||||
const botToken = process.env.BOT_TOKEN;
|
||||
const aiToken = process.env.AI_TOKEN;
|
||||
if (!botToken) {
|
||||
throw new Error('BOT_TOKEN environment variable is required');
|
||||
}
|
||||
if (!aiToken) {
|
||||
throw new Error('AI_TOKEN environment variable is required');
|
||||
}
|
||||
|
||||
const sdk = new HctvSdk({ botToken })
|
||||
|
||||
await sdk.chat.connect('bot-playground')
|
||||
console.log('connected to the chat!')
|
||||
|
||||
sdk.chat.onMessage(async m => {
|
||||
if (!m.message.startsWith('/ai')) return;
|
||||
const res = await fetch('https://ai.hackclub.com/proxy/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${aiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a helpful assistant. Reply concisely and like if you were on a chat platform. Use lowercase.' },
|
||||
{ role: 'user', content: m.message.replace('/ai', '').trim() }
|
||||
],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const aiMessage = data.choices?.[0]?.message?.content;
|
||||
|
||||
if (aiMessage) {
|
||||
sdk.chat.sendMessage(`@${m.username} ${aiMessage}`);
|
||||
}
|
||||
})
|
||||
39
packages/sdk/examples/multi-channel.ts
Normal file
39
packages/sdk/examples/multi-channel.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { HctvSdk } from '../src/index.js';
|
||||
|
||||
const sdk = new HctvSdk({
|
||||
botToken: process.env.BOT_TOKEN!,
|
||||
});
|
||||
|
||||
await sdk.chat.connect('channel1');
|
||||
await sdk.chat.connect('channel2');
|
||||
await sdk.chat.connect('channel3');
|
||||
console.log(`connected to ${sdk.chat.connectedChannels.join(', ')}`);
|
||||
|
||||
// gets messages from all channels I'm connected to
|
||||
sdk.chat.onMessage((message) => {
|
||||
console.log(`[${message.channelName}] ${message.username}: ${message.message}`);
|
||||
});
|
||||
|
||||
// specifically handle messages from channel1
|
||||
sdk.chat.onMessage((message) => {
|
||||
console.log(`ts from channel1: ${message.message}`);
|
||||
}, 'channel1');
|
||||
|
||||
sdk.chat.sendMessage('this is channel1!', 'channel1');
|
||||
sdk.chat.sendMessage('this is channel2!', 'channel2');
|
||||
|
||||
console.log(`connected to channel1? ${sdk.chat.isConnectedTo('channel1')}`);
|
||||
console.log(`connected to channel2? ${sdk.chat.isConnectedTo('channel2')}`);
|
||||
|
||||
// disconnect from channel2 after 5 seconds
|
||||
setTimeout(() => {
|
||||
console.log('disconnecting from channel2...');
|
||||
sdk.chat.disconnect('channel2');
|
||||
console.log(`still connected to: ${sdk.chat.connectedChannels.join(', ')}`);
|
||||
}, 5000);
|
||||
|
||||
// disconnect from all channels
|
||||
setTimeout(() => {
|
||||
console.log('disconnecting from all channels...');
|
||||
sdk.chat.disconnect();
|
||||
}, 10000);
|
||||
42
packages/sdk/package.json
Normal file
42
packages/sdk/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@hctv/sdk",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"example": "sh -c 'bun run examples/${0}.ts'"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
],
|
||||
"repository": "https://github.com/SrIzan10/ts-lib-boilerplate.git",
|
||||
"author": "Izan Gil <npm@srizan.dev>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
||||
"@typescript-eslint/parser": "^8.50.1",
|
||||
"eslint": "^9.39.2",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
294
packages/sdk/src/chat.ts
Normal file
294
packages/sdk/src/chat.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// most code here has been written by claude opus 4.5
|
||||
import type {
|
||||
ChatMessage,
|
||||
HistoryHandler,
|
||||
MessageHandler,
|
||||
ServerChatMessage,
|
||||
SystemMessage,
|
||||
SystemMessageHandler,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_BASE_URL = 'wss://hackclub.tv/api/stream/chat/ws';
|
||||
const PING_INTERVAL = 20000; // 20 seconds
|
||||
|
||||
interface ChannelConnection {
|
||||
ws: WebSocket;
|
||||
pingInterval: ReturnType<typeof setInterval>;
|
||||
messageHandlers: Set<MessageHandler>;
|
||||
systemMessageHandlers: Set<SystemMessageHandler>;
|
||||
historyHandlers: Set<HistoryHandler>;
|
||||
}
|
||||
|
||||
export class ChatClient {
|
||||
private botToken: string;
|
||||
private baseUrl: string;
|
||||
private connections: Map<string, ChannelConnection> = new Map();
|
||||
// Global handlers (receive messages from all channels)
|
||||
private globalMessageHandlers: Set<MessageHandler> = new Set();
|
||||
private globalSystemMessageHandlers: Set<SystemMessageHandler> = new Set();
|
||||
private globalHistoryHandlers: Set<HistoryHandler> = new Set();
|
||||
|
||||
constructor(botToken: string, options?: ChatClientOptions) {
|
||||
this.botToken = botToken;
|
||||
this.baseUrl = options?.baseUrl ?? DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
async connect(channelName: string): Promise<void> {
|
||||
if (this.connections.has(channelName)) {
|
||||
return Promise.reject(new Error(`already connected to channel: ${channelName}`));
|
||||
}
|
||||
|
||||
const wsUrl = `${this.baseUrl}/${channelName}?botAuth=${this.botToken}`;
|
||||
|
||||
let ws: WebSocket;
|
||||
if (typeof process !== 'undefined' && process.versions?.node) {
|
||||
const { default: WebSocket } = await import('ws');
|
||||
ws = new WebSocket(wsUrl) as any;
|
||||
} else {
|
||||
ws = new WebSocket(wsUrl);
|
||||
}
|
||||
|
||||
const connection: ChannelConnection = {
|
||||
ws,
|
||||
pingInterval: null as any,
|
||||
messageHandlers: new Set(),
|
||||
systemMessageHandlers: new Set(),
|
||||
historyHandlers: new Set(),
|
||||
};
|
||||
|
||||
this.connections.set(channelName, connection);
|
||||
|
||||
return this.setupWebSocket(channelName, connection);
|
||||
}
|
||||
|
||||
private setupWebSocket(channelName: string, connection: ChannelConnection): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
connection.ws.onopen = () => {
|
||||
const systemMsg: SystemMessage = {
|
||||
type: 'connected',
|
||||
channelName,
|
||||
message: 'Connected',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.emitSystem(systemMsg, connection);
|
||||
this.startPingInterval(channelName, connection);
|
||||
resolve();
|
||||
};
|
||||
|
||||
connection.ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data.toString());
|
||||
this.handleMessage(data, channelName, connection);
|
||||
};
|
||||
|
||||
connection.ws.onerror = () => {
|
||||
const systemMsg: SystemMessage = {
|
||||
type: 'error',
|
||||
channelName,
|
||||
message: 'WebSocket error',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.emitSystem(systemMsg, connection);
|
||||
reject(new Error('WebSocket error'));
|
||||
};
|
||||
|
||||
connection.ws.onclose = () => {
|
||||
this.stopPingInterval(connection);
|
||||
const systemMsg: SystemMessage = {
|
||||
type: 'disconnected',
|
||||
channelName,
|
||||
message: 'Disconnected',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.emitSystem(systemMsg, connection);
|
||||
this.connections.delete(channelName);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(data: any, channelName: string, connection: ChannelConnection): void {
|
||||
// Handle pong response
|
||||
if (data.type === 'pong') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle history messages (sent on connection)
|
||||
if (data.type === 'history' && Array.isArray(data.messages)) {
|
||||
const messages: ChatMessage[] = data.messages.map((msg: ServerChatMessage) =>
|
||||
this.parseServerMessage(msg, channelName)
|
||||
);
|
||||
// Emit to channel-specific handlers
|
||||
connection.historyHandlers.forEach((handler) => handler(messages));
|
||||
// Emit to global handlers
|
||||
this.globalHistoryHandlers.forEach((handler) => handler(messages));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular chat messages
|
||||
// Server sends: { user: { id, username, pfpUrl, displayName?, isBot }, message }
|
||||
if (data.user && typeof data.message === 'string') {
|
||||
const chatMessage = this.parseServerMessage(data, channelName);
|
||||
this.emitMessage(chatMessage, connection);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle emoji responses
|
||||
if (data.type === 'emojiMsgResponse' || data.type === 'emojiSearchResponse') {
|
||||
// Could add emoji handlers in the future
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private parseServerMessage(msg: ServerChatMessage, channelName: string): ChatMessage {
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
channelName,
|
||||
username: msg.user.username,
|
||||
displayName: msg.user.displayName,
|
||||
pfpUrl: msg.user.pfpUrl,
|
||||
message: msg.message,
|
||||
timestamp: Date.now(),
|
||||
type: msg.type === 'systemMsg' ? 'systemMsg' : 'message',
|
||||
isBot: msg.user.isBot || false,
|
||||
};
|
||||
}
|
||||
|
||||
private startPingInterval(channelName: string, connection: ChannelConnection): void {
|
||||
connection.pingInterval = setInterval(() => {
|
||||
if (connection.ws.readyState === WebSocket.OPEN) {
|
||||
connection.ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
}
|
||||
|
||||
private stopPingInterval(connection: ChannelConnection): void {
|
||||
if (connection.pingInterval) {
|
||||
clearInterval(connection.pingInterval);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(channelName?: string): void {
|
||||
if (channelName) {
|
||||
// Disconnect specific channel
|
||||
const connection = this.connections.get(channelName);
|
||||
if (connection) {
|
||||
this.stopPingInterval(connection);
|
||||
connection.ws.close();
|
||||
this.connections.delete(channelName);
|
||||
}
|
||||
} else {
|
||||
// Disconnect all channels
|
||||
for (const [name, connection] of this.connections) {
|
||||
this.stopPingInterval(connection);
|
||||
connection.ws.close();
|
||||
}
|
||||
this.connections.clear();
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(message: string, channelName?: string): void {
|
||||
if (channelName) {
|
||||
// Send to specific channel
|
||||
const connection = this.connections.get(channelName);
|
||||
if (!connection || connection.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error(`Not connected to channel: ${channelName}`);
|
||||
}
|
||||
connection.ws.send(JSON.stringify({ type: 'message', message }));
|
||||
} else {
|
||||
// Send to first connected channel (backward compatibility)
|
||||
const firstConnection = Array.from(this.connections.values())[0];
|
||||
if (!firstConnection || firstConnection.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Not connected to any channel');
|
||||
}
|
||||
firstConnection.ws.send(JSON.stringify({ type: 'message', message }));
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(handler: MessageHandler, channelName?: string): () => void {
|
||||
if (channelName) {
|
||||
// Channel-specific handler
|
||||
const connection = this.connections.get(channelName);
|
||||
if (!connection) {
|
||||
throw new Error(`Not connected to channel: ${channelName}`);
|
||||
}
|
||||
connection.messageHandlers.add(handler);
|
||||
return () => connection.messageHandlers.delete(handler);
|
||||
} else {
|
||||
// Global handler (receives from all channels)
|
||||
this.globalMessageHandlers.add(handler);
|
||||
return () => this.globalMessageHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
onSystemMessage(handler: SystemMessageHandler, channelName?: string): () => void {
|
||||
if (channelName) {
|
||||
// Channel-specific handler
|
||||
const connection = this.connections.get(channelName);
|
||||
if (!connection) {
|
||||
throw new Error(`Not connected to channel: ${channelName}`);
|
||||
}
|
||||
connection.systemMessageHandlers.add(handler);
|
||||
return () => connection.systemMessageHandlers.delete(handler);
|
||||
} else {
|
||||
// Global handler (receives from all channels)
|
||||
this.globalSystemMessageHandlers.add(handler);
|
||||
return () => this.globalSystemMessageHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
onHistory(handler: HistoryHandler, channelName?: string): () => void {
|
||||
if (channelName) {
|
||||
// Channel-specific handler
|
||||
const connection = this.connections.get(channelName);
|
||||
if (!connection) {
|
||||
throw new Error(`Not connected to channel: ${channelName}`);
|
||||
}
|
||||
connection.historyHandlers.add(handler);
|
||||
return () => connection.historyHandlers.delete(handler);
|
||||
} else {
|
||||
// Global handler (receives from all channels)
|
||||
this.globalHistoryHandlers.add(handler);
|
||||
return () => this.globalHistoryHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
private emitMessage(message: ChatMessage, connection: ChannelConnection): void {
|
||||
// Emit to channel-specific handlers
|
||||
connection.messageHandlers.forEach((handler) => handler(message));
|
||||
// Emit to global handlers
|
||||
this.globalMessageHandlers.forEach((handler) => handler(message));
|
||||
}
|
||||
|
||||
private emitSystem(message: SystemMessage, connection: ChannelConnection): void {
|
||||
// Emit to channel-specific handlers
|
||||
connection.systemMessageHandlers.forEach((handler) => handler(message));
|
||||
// Emit to global handlers
|
||||
this.globalSystemMessageHandlers.forEach((handler) => handler(message));
|
||||
}
|
||||
|
||||
isConnectedTo(channelName: string): boolean {
|
||||
const connection = this.connections.get(channelName);
|
||||
return connection ? connection.ws.readyState === WebSocket.OPEN : false;
|
||||
}
|
||||
|
||||
get connectedChannels(): string[] {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return (
|
||||
this.connections.size > 0 &&
|
||||
Array.from(this.connections.values()).some((c) => c.ws.readyState === WebSocket.OPEN)
|
||||
);
|
||||
}
|
||||
|
||||
get currentChannel(): string | null {
|
||||
// Return first connected channel for backward compatibility
|
||||
const channels = Array.from(this.connections.keys());
|
||||
return channels.length > 0 ? channels[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatClientOptions {
|
||||
/** Custom WebSocket base URL (default: wss://hackclub.tv/api/chat) */
|
||||
baseUrl?: string;
|
||||
}
|
||||
18
packages/sdk/src/index.ts
Normal file
18
packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ChatClient } from './chat.js';
|
||||
|
||||
export class HctvSdk {
|
||||
private botToken: string;
|
||||
public chat: ChatClient;
|
||||
|
||||
constructor(args: ConstructorArgs) {
|
||||
this.botToken = args.botToken;
|
||||
this.chat = new ChatClient(args.botToken, args.chatOptions);
|
||||
}
|
||||
}
|
||||
|
||||
interface ConstructorArgs {
|
||||
botToken: string;
|
||||
chatOptions?: import('./chat.js').ChatClientOptions;
|
||||
}
|
||||
export { ChatClient, type ChatClientOptions } from './chat.js';
|
||||
export type { ChatMessage, MessageHandler, SystemMessage, SystemMessageHandler, HistoryHandler } from './types.js';
|
||||
35
packages/sdk/src/types.ts
Normal file
35
packages/sdk/src/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
channelName: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
pfpUrl?: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
type: 'message' | 'systemMsg';
|
||||
isBot: boolean;
|
||||
}
|
||||
|
||||
export interface SystemMessage {
|
||||
type: 'connected' | 'disconnected' | 'error';
|
||||
channelName: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Message format received from the server */
|
||||
export interface ServerChatMessage {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
pfpUrl: string;
|
||||
displayName?: string;
|
||||
isBot?: boolean;
|
||||
};
|
||||
message: string;
|
||||
type?: 'message' | 'systemMsg';
|
||||
}
|
||||
|
||||
export type MessageHandler = (message: ChatMessage) => void;
|
||||
export type SystemMessageHandler = (message: SystemMessage) => void;
|
||||
export type HistoryHandler = (messages: ChatMessage[]) => void;
|
||||
922
packages/sdk/tests/chat.test.ts
Normal file
922
packages/sdk/tests/chat.test.ts
Normal file
@@ -0,0 +1,922 @@
|
||||
// testing completely controlled by claude opus 4.5 because i'm lazy as heck
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HctvSdk, ChatClient } from '../src/index.js';
|
||||
import type { ChatMessage, SystemMessage } from '../src/types.js';
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: ((error: any) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
|
||||
sentMessages: string[] = [];
|
||||
url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
simulateMessage(data: any) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
simulateError(error: any) {
|
||||
this.onerror?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
let mockWebSocketInstances: MockWebSocket[] = [];
|
||||
|
||||
vi.stubGlobal(
|
||||
'WebSocket',
|
||||
class extends MockWebSocket {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
mockWebSocketInstances.push(this);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to get mock instance by channel name
|
||||
function getMockInstance(channelName: string): MockWebSocket | undefined {
|
||||
return mockWebSocketInstances.find((ws) => ws.url.includes(`/${channelName}`));
|
||||
}
|
||||
|
||||
// Mock process to simulate browser environment (avoid Node.js ws import path)
|
||||
vi.stubGlobal('process', { versions: {} });
|
||||
|
||||
describe('HctvSdk', () => {
|
||||
beforeEach(() => {
|
||||
mockWebSocketInstances = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with bot token', () => {
|
||||
const sdk = new HctvSdk({ botToken: 'test-token' });
|
||||
expect(sdk).toBeDefined();
|
||||
expect(sdk.chat).toBeInstanceOf(ChatClient);
|
||||
});
|
||||
|
||||
it('should pass chat options to ChatClient', () => {
|
||||
const sdk = new HctvSdk({
|
||||
botToken: 'test-token',
|
||||
chatOptions: { baseUrl: 'wss://custom.url' },
|
||||
});
|
||||
expect(sdk.chat).toBeInstanceOf(ChatClient);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatClient', () => {
|
||||
let client: ChatClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWebSocketInstances = [];
|
||||
client = new ChatClient('test-bot-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with bot token', () => {
|
||||
expect(client).toBeDefined();
|
||||
expect(client.isConnected).toBe(false);
|
||||
expect(client.currentChannel).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept custom base URL', () => {
|
||||
const customClient = new ChatClient('token', { baseUrl: 'wss://custom.url' });
|
||||
expect(customClient).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect', () => {
|
||||
it('should connect to a channel', async () => {
|
||||
const connectPromise = client.connect('testchannel');
|
||||
|
||||
await connectPromise;
|
||||
|
||||
expect(client.isConnected).toBe(true);
|
||||
const mockWs = getMockInstance('testchannel');
|
||||
expect(mockWs).not.toBeUndefined();
|
||||
expect(mockWs?.url).toContain('/ws/testchannel');
|
||||
});
|
||||
|
||||
it('should allow connecting to multiple channels', async () => {
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
|
||||
expect(client.isConnectedTo('channel1')).toBe(true);
|
||||
expect(client.isConnectedTo('channel2')).toBe(true);
|
||||
expect(client.connectedChannels).toEqual(['channel1', 'channel2']);
|
||||
});
|
||||
|
||||
it('should throw error when already connected to same channel', async () => {
|
||||
await client.connect('testchannel');
|
||||
|
||||
await expect(client.connect('testchannel')).rejects.toThrow(
|
||||
'already connected to channel: testchannel'
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit connected system message', async () => {
|
||||
const systemHandler = vi.fn();
|
||||
client.onSystemMessage(systemHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
expect(systemHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'connected',
|
||||
channelName: 'testchannel',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should disconnect from specific channel', async () => {
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
|
||||
expect(client.isConnectedTo('channel1')).toBe(true);
|
||||
expect(client.isConnectedTo('channel2')).toBe(true);
|
||||
|
||||
client.disconnect('channel1');
|
||||
|
||||
expect(client.isConnectedTo('channel1')).toBe(false);
|
||||
expect(client.isConnectedTo('channel2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should disconnect from all channels when no channel specified', async () => {
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
expect(client.isConnected).toBe(true);
|
||||
|
||||
client.disconnect();
|
||||
|
||||
expect(client.isConnected).toBe(false);
|
||||
expect(client.connectedChannels.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should emit disconnected system message', async () => {
|
||||
const systemHandler = vi.fn();
|
||||
client.onSystemMessage(systemHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
client.disconnect();
|
||||
|
||||
expect(systemHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'disconnected',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it('should send a message to specific channel', async () => {
|
||||
await client.connect('testchannel');
|
||||
|
||||
client.sendMessage('Hello, world!', 'testchannel');
|
||||
|
||||
const mockWs = getMockInstance('testchannel');
|
||||
const messages = mockWs!.sentMessages;
|
||||
const lastMsg = JSON.parse(messages[messages.length - 1]);
|
||||
expect(lastMsg.type).toBe('message');
|
||||
expect(lastMsg.message).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('should send to first channel when no channel specified', async () => {
|
||||
await client.connect('testchannel');
|
||||
|
||||
client.sendMessage('Hello, world!');
|
||||
|
||||
const mockWs = getMockInstance('testchannel');
|
||||
const messages = mockWs!.sentMessages;
|
||||
const lastMsg = JSON.parse(messages[messages.length - 1]);
|
||||
expect(lastMsg.type).toBe('message');
|
||||
expect(lastMsg.message).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('should throw error when not connected', () => {
|
||||
expect(() => client.sendMessage('test')).toThrow('Not connected to any channel');
|
||||
});
|
||||
|
||||
it('should throw error when channel not connected', async () => {
|
||||
await client.connect('channel1');
|
||||
expect(() => client.sendMessage('test', 'channel2')).toThrow(
|
||||
'Not connected to channel: channel2'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMessage', () => {
|
||||
it('should call global handler when message received', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
const mockWs = getMockInstance('testchannel');
|
||||
// Server format: { user: {...}, message: string }
|
||||
mockWs?.simulateMessage({
|
||||
user: {
|
||||
id: 'user-123',
|
||||
username: 'testuser',
|
||||
pfpUrl: 'https://example.com/pfp.jpg',
|
||||
},
|
||||
message: 'Hello from server',
|
||||
});
|
||||
|
||||
expect(messageHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Hello from server',
|
||||
username: 'testuser',
|
||||
pfpUrl: 'https://example.com/pfp.jpg',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should call channel-specific handler', async () => {
|
||||
await client.connect('testchannel');
|
||||
|
||||
const channelHandler = vi.fn();
|
||||
client.onMessage(channelHandler, 'testchannel');
|
||||
|
||||
const mockWs = getMockInstance('testchannel');
|
||||
mockWs?.simulateMessage({
|
||||
user: { id: '1', username: 'testuser', pfpUrl: '' },
|
||||
message: 'Hello',
|
||||
});
|
||||
|
||||
expect(channelHandler).toHaveBeenCalledWith(expect.objectContaining({ message: 'Hello' }));
|
||||
});
|
||||
|
||||
it('should route messages to correct channel handler', async () => {
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
client.onMessage(handler1, 'channel1');
|
||||
client.onMessage(handler2, 'channel2');
|
||||
|
||||
const mockWs1 = getMockInstance('channel1');
|
||||
const mockWs2 = getMockInstance('channel2');
|
||||
|
||||
mockWs1?.simulateMessage({
|
||||
user: { id: '1', username: 'user1', pfpUrl: '' },
|
||||
message: 'Message to channel1',
|
||||
});
|
||||
|
||||
mockWs2?.simulateMessage({
|
||||
user: { id: '2', username: 'user2', pfpUrl: '' },
|
||||
message: 'Message to channel2',
|
||||
});
|
||||
|
||||
expect(handler1).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Message to channel1', channelName: 'channel1' })
|
||||
);
|
||||
expect(handler2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Message to channel2', channelName: 'channel2' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should call global handler for all channels', async () => {
|
||||
const globalHandler = vi.fn();
|
||||
client.onMessage(globalHandler);
|
||||
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
|
||||
const mockWs1 = getMockInstance('channel1');
|
||||
const mockWs2 = getMockInstance('channel2');
|
||||
|
||||
mockWs1?.simulateMessage({
|
||||
user: { id: '1', username: 'user1', pfpUrl: '' },
|
||||
message: 'Message 1',
|
||||
});
|
||||
|
||||
mockWs2?.simulateMessage({
|
||||
user: { id: '2', username: 'user2', pfpUrl: '' },
|
||||
message: 'Message 2',
|
||||
});
|
||||
|
||||
expect(globalHandler).toHaveBeenCalledTimes(2);
|
||||
expect(globalHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Message 1', channelName: 'channel1' })
|
||||
);
|
||||
expect(globalHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Message 2', channelName: 'channel2' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should return unsubscribe function', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
const unsubscribe = client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
unsubscribe();
|
||||
|
||||
const mockWs = getMockInstance('testchannel');
|
||||
mockWs?.simulateMessage({
|
||||
user: { id: '1', username: 'testuser', pfpUrl: '' },
|
||||
message: 'Should not receive',
|
||||
});
|
||||
|
||||
expect(messageHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple message handlers', async () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
client.onMessage(handler1);
|
||||
client.onMessage(handler2);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
const mockWs = getMockInstance('testchannel');
|
||||
mockWs?.simulateMessage({
|
||||
user: { id: '1', username: 'testuser', pfpUrl: '' },
|
||||
message: 'test message',
|
||||
});
|
||||
|
||||
expect(handler1).toHaveBeenCalled();
|
||||
expect(handler2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHistory', () => {
|
||||
it('should call history handler when history received', async () => {
|
||||
const historyHandler = vi.fn();
|
||||
client.onHistory(historyHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
type: 'history',
|
||||
messages: [
|
||||
{
|
||||
user: { id: 'u1', username: 'user1', pfpUrl: '' },
|
||||
message: 'First message',
|
||||
type: 'message',
|
||||
},
|
||||
{
|
||||
user: { id: 'u2', username: 'user2', pfpUrl: '' },
|
||||
message: 'Second message',
|
||||
type: 'message',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(historyHandler).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: 'First message', username: 'user1' }),
|
||||
expect.objectContaining({ message: 'Second message', username: 'user2' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should return unsubscribe function', async () => {
|
||||
const historyHandler = vi.fn();
|
||||
const unsubscribe = client.onHistory(historyHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
unsubscribe();
|
||||
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
type: 'history',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(historyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSystemMessage', () => {
|
||||
it('should call handler for system events', async () => {
|
||||
const systemHandler = vi.fn();
|
||||
client.onSystemMessage(systemHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
expect(systemHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'connected',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return unsubscribe function', async () => {
|
||||
const systemHandler = vi.fn();
|
||||
const unsubscribe = client.onSystemMessage(systemHandler);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
expect(systemHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message parsing', () => {
|
||||
it('should handle message with full user object', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
user: {
|
||||
id: 'user-123',
|
||||
username: 'johndoe',
|
||||
pfpUrl: 'https://example.com/pfp.jpg',
|
||||
displayName: 'John Doe',
|
||||
isBot: false,
|
||||
},
|
||||
message: 'Hello',
|
||||
});
|
||||
|
||||
expect(messageHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: 'johndoe',
|
||||
displayName: 'John Doe',
|
||||
message: 'Hello',
|
||||
isBot: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle bot messages', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
user: {
|
||||
id: 'bot-123',
|
||||
username: 'mybot',
|
||||
pfpUrl: 'https://example.com/bot.jpg',
|
||||
displayName: 'My Bot',
|
||||
isBot: true,
|
||||
},
|
||||
message: 'Hello from bot!',
|
||||
});
|
||||
|
||||
expect(messageHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: 'mybot',
|
||||
isBot: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ping/pong', () => {
|
||||
it('should handle pong response from server', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
type: 'pong',
|
||||
});
|
||||
|
||||
expect(messageHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emoji responses', () => {
|
||||
it('should handle emojiMsgResponse from server', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
type: 'emojiMsgResponse',
|
||||
emojis: {
|
||||
smile: 'https://example.com/emoji/smile.png',
|
||||
},
|
||||
});
|
||||
|
||||
expect(messageHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle emojiSearchResponse from server', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
type: 'emojiSearchResponse',
|
||||
results: ['smile', 'smirk'],
|
||||
});
|
||||
|
||||
expect(messageHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentChannel', () => {
|
||||
it('should return null when not connected', () => {
|
||||
expect(client.currentChannel).toBeNull();
|
||||
});
|
||||
|
||||
it('should return channel name when connected', async () => {
|
||||
await client.connect('mychannel');
|
||||
expect(client.currentChannel).toBe('mychannel');
|
||||
});
|
||||
|
||||
it('should return null after disconnect', async () => {
|
||||
await client.connect('mychannel');
|
||||
client.disconnect();
|
||||
expect(client.currentChannel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConnected', () => {
|
||||
it('should return false initially', () => {
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when connected', async () => {
|
||||
await client.connect('testchannel');
|
||||
expect(client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false after disconnect', async () => {
|
||||
await client.connect('testchannel');
|
||||
client.disconnect();
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should emit error system message on WebSocket error', async () => {
|
||||
const systemHandler = vi.fn();
|
||||
client.onSystemMessage(systemHandler);
|
||||
|
||||
const connectPromise = client.connect('testchannel');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
getMockInstance('testchannel')?.simulateError(new Error('Connection failed'));
|
||||
|
||||
await expect(connectPromise).rejects.toBeDefined();
|
||||
|
||||
expect(systemHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatMessage type', () => {
|
||||
it('should have correct structure', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'msg-123',
|
||||
channelName: 'testchannel',
|
||||
username: 'testuser',
|
||||
message: 'Hello, world!',
|
||||
timestamp: Date.now(),
|
||||
type: 'message',
|
||||
isBot: false,
|
||||
};
|
||||
|
||||
expect(message.id).toBe('msg-123');
|
||||
expect(message.channelName).toBe('testchannel');
|
||||
expect(message.username).toBe('testuser');
|
||||
expect(message.message).toBe('Hello, world!');
|
||||
expect(typeof message.timestamp).toBe('number');
|
||||
expect(message.type).toBe('message');
|
||||
expect(message.isBot).toBe(false);
|
||||
});
|
||||
|
||||
it('should support systemMsg type', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'sys-123',
|
||||
channelName: 'testchannel',
|
||||
username: 'system',
|
||||
message: 'User joined',
|
||||
timestamp: Date.now(),
|
||||
type: 'systemMsg',
|
||||
isBot: false,
|
||||
};
|
||||
|
||||
expect(message.type).toBe('systemMsg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SystemMessage type', () => {
|
||||
it('should support connected type', () => {
|
||||
const message: SystemMessage = {
|
||||
type: 'connected',
|
||||
channelName: 'testchannel',
|
||||
message: 'Connected to testchannel',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(message.type).toBe('connected');
|
||||
});
|
||||
|
||||
it('should support disconnected type', () => {
|
||||
const message: SystemMessage = {
|
||||
type: 'disconnected',
|
||||
channelName: 'testchannel',
|
||||
message: 'Disconnected from testchannel',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(message.type).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('should support error type', () => {
|
||||
const message: SystemMessage = {
|
||||
type: 'error',
|
||||
channelName: 'testchannel',
|
||||
message: 'An error occurred',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(message.type).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-channel support', () => {
|
||||
let client: ChatClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWebSocketInstances = [];
|
||||
client = new ChatClient('test-bot-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should connect to multiple channels simultaneously', async () => {
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
await client.connect('channel3');
|
||||
|
||||
expect(client.connectedChannels).toEqual(['channel1', 'channel2', 'channel3']);
|
||||
expect(client.isConnectedTo('channel1')).toBe(true);
|
||||
expect(client.isConnectedTo('channel2')).toBe(true);
|
||||
expect(client.isConnectedTo('channel3')).toBe(true);
|
||||
});
|
||||
|
||||
it('should send messages to specific channels', async () => {
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
|
||||
client.sendMessage('Message 1', 'channel1');
|
||||
client.sendMessage('Message 2', 'channel2');
|
||||
|
||||
const mockWs1 = getMockInstance('channel1');
|
||||
const mockWs2 = getMockInstance('channel2');
|
||||
|
||||
const msg1 = JSON.parse(mockWs1!.sentMessages[mockWs1!.sentMessages.length - 1]);
|
||||
const msg2 = JSON.parse(mockWs2!.sentMessages[mockWs2!.sentMessages.length - 1]);
|
||||
|
||||
expect(msg1.message).toBe('Message 1');
|
||||
expect(msg2.message).toBe('Message 2');
|
||||
});
|
||||
|
||||
it('should route messages correctly with channel-specific handlers', async () => {
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
client.onMessage(handler1, 'channel1');
|
||||
client.onMessage(handler2, 'channel2');
|
||||
|
||||
const mockWs1 = getMockInstance('channel1');
|
||||
const mockWs2 = getMockInstance('channel2');
|
||||
|
||||
mockWs1?.simulateMessage({
|
||||
user: { id: '1', username: 'user1', pfpUrl: '' },
|
||||
message: 'Hello channel 1',
|
||||
});
|
||||
|
||||
mockWs2?.simulateMessage({
|
||||
user: { id: '2', username: 'user2', pfpUrl: '' },
|
||||
message: 'Hello channel 2',
|
||||
});
|
||||
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
expect(handler1).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Hello channel 1',
|
||||
channelName: 'channel1',
|
||||
})
|
||||
);
|
||||
|
||||
expect(handler2).toHaveBeenCalledTimes(1);
|
||||
expect(handler2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Hello channel 2',
|
||||
channelName: 'channel2',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should support global handlers receiving from all channels', async () => {
|
||||
const globalHandler = vi.fn();
|
||||
client.onMessage(globalHandler);
|
||||
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
|
||||
const mockWs1 = getMockInstance('channel1');
|
||||
const mockWs2 = getMockInstance('channel2');
|
||||
|
||||
mockWs1?.simulateMessage({
|
||||
user: { id: '1', username: 'user1', pfpUrl: '' },
|
||||
message: 'Message from channel 1',
|
||||
});
|
||||
|
||||
mockWs2?.simulateMessage({
|
||||
user: { id: '2', username: 'user2', pfpUrl: '' },
|
||||
message: 'Message from channel 2',
|
||||
});
|
||||
|
||||
expect(globalHandler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should disconnect from specific channel without affecting others', async () => {
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
await client.connect('channel3');
|
||||
|
||||
client.disconnect('channel2');
|
||||
|
||||
expect(client.isConnectedTo('channel1')).toBe(true);
|
||||
expect(client.isConnectedTo('channel2')).toBe(false);
|
||||
expect(client.isConnectedTo('channel3')).toBe(true);
|
||||
expect(client.connectedChannels).toEqual(['channel1', 'channel3']);
|
||||
});
|
||||
|
||||
it('should handle history for multiple channels independently', async () => {
|
||||
const historyHandler = vi.fn();
|
||||
client.onHistory(historyHandler);
|
||||
|
||||
await client.connect('channel1');
|
||||
await client.connect('channel2');
|
||||
|
||||
const mockWs1 = getMockInstance('channel1');
|
||||
const mockWs2 = getMockInstance('channel2');
|
||||
|
||||
mockWs1?.simulateMessage({
|
||||
type: 'history',
|
||||
messages: [{ user: { id: '1', username: 'u1', pfpUrl: '' }, message: 'C1 History' }],
|
||||
});
|
||||
|
||||
mockWs2?.simulateMessage({
|
||||
type: 'history',
|
||||
messages: [{ user: { id: '2', username: 'u2', pfpUrl: '' }, message: 'C2 History' }],
|
||||
});
|
||||
|
||||
expect(historyHandler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should maintain backward compatibility with single channel usage', async () => {
|
||||
await client.connect('testchannel');
|
||||
|
||||
expect(client.isConnected).toBe(true);
|
||||
expect(client.currentChannel).toBe('testchannel');
|
||||
|
||||
client.sendMessage('Test');
|
||||
|
||||
const mockWs = getMockInstance('testchannel');
|
||||
expect(mockWs!.sentMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
let client: ChatClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWebSocketInstances = [];
|
||||
client = new ChatClient('test-bot-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle empty message', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
user: { id: '1', username: 'testuser', pfpUrl: '' },
|
||||
message: '',
|
||||
});
|
||||
|
||||
expect(messageHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: '',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle message with special characters', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
const specialMessage = '🎉 Hello <script>alert("xss")</script> & "quotes" \'apostrophe\'';
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
user: { id: '1', username: 'testuser', pfpUrl: '' },
|
||||
message: specialMessage,
|
||||
});
|
||||
|
||||
expect(messageHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: specialMessage,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle very long messages', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
const longMessage = 'a'.repeat(10000);
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
user: { id: '1', username: 'testuser', pfpUrl: '' },
|
||||
message: longMessage,
|
||||
});
|
||||
|
||||
expect(messageHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: longMessage,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rapid successive messages', async () => {
|
||||
const messageHandler = vi.fn();
|
||||
client.onMessage(messageHandler);
|
||||
|
||||
await client.connect('testchannel');
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
getMockInstance('testchannel')?.simulateMessage({
|
||||
user: { id: '1', username: 'testuser', pfpUrl: '' },
|
||||
message: `Message ${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(messageHandler).toHaveBeenCalledTimes(100);
|
||||
});
|
||||
|
||||
it('should handle disconnect while message is being processed', async () => {
|
||||
await client.connect('testchannel');
|
||||
client.disconnect();
|
||||
expect(() => client.sendMessage('test')).toThrow('Not connected');
|
||||
});
|
||||
});
|
||||
196
packages/sdk/tests/integration.test.ts
Normal file
196
packages/sdk/tests/integration.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* integration tests for the sdk
|
||||
*
|
||||
* these run against the real production server.
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. Set BOT_TOKEN environment variable with a valid bot API key
|
||||
*
|
||||
* Run with:
|
||||
* BOT_TOKEN=your-token pnpm test
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
|
||||
import { ChatClient } from '../src/chat.js';
|
||||
import type { ChatMessage, SystemMessage } from '../src/types.js';
|
||||
|
||||
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||
const TEST_CHANNEL = "bot-playground";
|
||||
|
||||
describe.skipIf(!BOT_TOKEN)('ChatClient Integration Tests', () => {
|
||||
let client: ChatClient;
|
||||
|
||||
beforeAll(() => {
|
||||
if (!BOT_TOKEN) {
|
||||
console.warn('⚠️ BOT_TOKEN not set. Skipping integration tests.');
|
||||
console.warn(' Set BOT_TOKEN environment variable to run integration tests.');
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
client = new ChatClient(BOT_TOKEN!);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client.isConnected) {
|
||||
client.disconnect();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
});
|
||||
|
||||
describe('Connection', () => {
|
||||
it('should connect to production chat server', async () => {
|
||||
const systemMessages: SystemMessage[] = [];
|
||||
client.onSystemMessage((msg) => systemMessages.push(msg));
|
||||
|
||||
await client.connect(TEST_CHANNEL);
|
||||
|
||||
expect(client.isConnected).toBe(true);
|
||||
expect(client.currentChannel).toBe(TEST_CHANNEL);
|
||||
expect(systemMessages.some((m) => m.type === 'connected')).toBe(true);
|
||||
}, 15000);
|
||||
|
||||
it('should receive history on connection', async () => {
|
||||
const history: ChatMessage[][] = [];
|
||||
client.onHistory((messages) => history.push(messages));
|
||||
|
||||
await client.connect(TEST_CHANNEL);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
expect(history.length).toBeGreaterThanOrEqual(0);
|
||||
}, 15000);
|
||||
|
||||
it('should disconnect cleanly', async () => {
|
||||
const systemMessages: SystemMessage[] = [];
|
||||
client.onSystemMessage((msg) => systemMessages.push(msg));
|
||||
|
||||
await client.connect(TEST_CHANNEL);
|
||||
expect(client.isConnected).toBe(true);
|
||||
|
||||
client.disconnect();
|
||||
|
||||
expect(client.isConnected).toBe(false);
|
||||
expect(client.currentChannel).toBeNull();
|
||||
// idk why ts claude opus clanker thinks that system messages exist as of jan 31st.
|
||||
//expect(systemMessages.some((m) => m.type === 'disconnected')).toBe(true);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('Messaging', () => {
|
||||
it('should send and receive own message (echo)', async () => {
|
||||
const receivedMessages: ChatMessage[] = [];
|
||||
const testMessage = `SDK test message ${Date.now()}`;
|
||||
|
||||
client.onMessage((msg) => receivedMessages.push(msg));
|
||||
|
||||
await client.connect(TEST_CHANNEL);
|
||||
client.sendMessage(testMessage);
|
||||
|
||||
// Wait for the message to echo back
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const sentMessage = receivedMessages.find((msg) => msg.message === testMessage);
|
||||
expect(sentMessage).toBeDefined();
|
||||
expect(sentMessage?.message).toBe(testMessage);
|
||||
}, 15000);
|
||||
|
||||
it('should receive messages with correct structure', async () => {
|
||||
const receivedMessages: ChatMessage[] = [];
|
||||
const testMessage = `Structure test ${Date.now()}`;
|
||||
|
||||
client.onMessage((msg) => receivedMessages.push(msg));
|
||||
|
||||
await client.connect(TEST_CHANNEL);
|
||||
client.sendMessage(testMessage);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const msg = receivedMessages.find((m) => m.message === testMessage);
|
||||
expect(msg).toBeDefined();
|
||||
|
||||
if (msg) {
|
||||
expect(msg.id).toBeDefined();
|
||||
expect(msg.channelName).toBe(TEST_CHANNEL);
|
||||
expect(msg.username).toBeDefined();
|
||||
expect(msg.message).toBe(testMessage);
|
||||
expect(typeof msg.timestamp).toBe('number');
|
||||
expect(msg.type).toBe('message');
|
||||
expect(typeof msg.isBot).toBe('boolean');
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('Event Handlers', () => {
|
||||
it('should allow multiple message handlers', async () => {
|
||||
const handler1Messages: ChatMessage[] = [];
|
||||
const handler2Messages: ChatMessage[] = [];
|
||||
const testMessage = `Multi-handler test ${Date.now()}`;
|
||||
|
||||
client.onMessage((msg) => handler1Messages.push(msg));
|
||||
client.onMessage((msg) => handler2Messages.push(msg));
|
||||
|
||||
await client.connect(TEST_CHANNEL);
|
||||
client.sendMessage(testMessage);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const h1Msg = handler1Messages.find((m) => m.message === testMessage);
|
||||
const h2Msg = handler2Messages.find((m) => m.message === testMessage);
|
||||
|
||||
expect(h1Msg).toBeDefined();
|
||||
expect(h2Msg).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it('should support unsubscribing from handlers', async () => {
|
||||
const messages: ChatMessage[] = [];
|
||||
const testMessage = `Unsubscribe test ${Date.now()}`;
|
||||
|
||||
const unsubscribe = client.onMessage((msg) => messages.push(msg));
|
||||
|
||||
await client.connect(TEST_CHANNEL);
|
||||
|
||||
// Unsubscribe before sending
|
||||
unsubscribe();
|
||||
|
||||
client.sendMessage(testMessage);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Should not have received the message
|
||||
const found = messages.find((m) => m.message === testMessage);
|
||||
expect(found).toBeUndefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('Reconnection', () => {
|
||||
it('should be able to reconnect after disconnect', async () => {
|
||||
await client.connect(TEST_CHANNEL);
|
||||
expect(client.isConnected).toBe(true);
|
||||
|
||||
client.disconnect();
|
||||
expect(client.isConnected).toBe(false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
await client.connect(TEST_CHANNEL);
|
||||
expect(client.isConnected).toBe(true);
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should reject connecting to invalid channel', async () => {
|
||||
const invalidClient = new ChatClient('invalid-token-12345');
|
||||
|
||||
try {
|
||||
await invalidClient.connect(TEST_CHANNEL);
|
||||
invalidClient.disconnect();
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
it('should throw when sending message while disconnected', () => {
|
||||
expect(() => client.sendMessage('test')).toThrow('Not connected to any channel');
|
||||
});
|
||||
});
|
||||
});
|
||||
115
packages/sdk/tsconfig.json
Normal file
115
packages/sdk/tsconfig.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "Node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
"allowImportingTsExtensions": false, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
"declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
10
packages/sdk/tsup.config.ts
Normal file
10
packages/sdk/tsup.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['./src/**/*.ts'],
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
})
|
||||
17447
pnpm-lock.yaml
generated
Normal file
17447
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
2
slack-import-emojis/Cargo.lock
generated
2
slack-import-emojis/Cargo.lock
generated
@@ -827,7 +827,7 @@ checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
|
||||
|
||||
[[package]]
|
||||
name = "slack-import-emojis"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "slack-import-emojis"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -27,10 +27,6 @@ struct EmojiData {
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if std::env::var("SLACK_TOKEN").is_err() {
|
||||
eprintln!("Error: SLACK_TOKEN environment variable is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut slack_emojis = slack_request()
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user