Compare commits

..

31 Commits

Author SHA1 Message Date
17f550ce9d chore: some toast stuff 2025-09-30 17:46:09 +02:00
ebd0a8bffd chore: separator 2025-09-30 17:33:42 +02:00
2298b8bc0c feat: small amount of documentation 2025-09-30 17:31:07 +02:00
7dd9bf765e feat: chats work now! 2025-09-30 17:21:06 +02:00
8b3df28f1e feat: back button 2025-09-30 08:09:56 +02:00
ecca138257 chore: listen to enter key 2025-09-30 08:06:40 +02:00
cb0f75dfb9 feat: bot accounts (without api stuff) 2025-09-30 08:00:19 +02:00
747af0aeb3 fix: not right cwd 2025-09-05 00:53:56 +02:00
64c7a80883 fix: vips stuff again aaa 2025-09-05 00:50:06 +02:00
e8fdfa8f49 chore: add vips-dev to both stages 2025-09-05 00:06:22 +02:00
9e965c779f chore: also install vips inside installer 2025-09-04 23:51:21 +02:00
9ce6770115 fix: libvips not installed in container 2025-09-04 23:42:59 +02:00
6d413bc53e fix: onrequesterror not exposed 2025-09-04 23:34:58 +02:00
93ae6bd73e fix: move sentry server to instrumentation instead 2025-09-04 23:24:06 +02:00
ae99121af6 fix: sentry errors not being verbose enough 2025-09-04 23:20:37 +02:00
331a0185af chore: remove all monorepo readmes 2025-09-04 17:03:16 +02:00
d223902a9f ci: add default flag 2025-09-04 17:01:16 +02:00
18a00bba6e fix: emojis not showing + some optimizations 2025-09-03 23:35:07 +02:00
061d1d3348 chore: up version 2025-09-03 23:20:43 +02:00
95ec96fe72 feat: add default emojis 2025-09-03 23:15:36 +02:00
9eca54cbb5 fix: follow notifications showing untrue information 2025-09-03 22:41:45 +02:00
31fa5f36de feat: add 24/7 toggle (also make label optional) 2025-09-03 22:37:07 +02:00
d327da90ef chore: add 24/7 check to notification stuff 2025-09-03 00:54:40 +02:00
7072b762d8 fix: build issues 2025-09-02 19:58:36 +02:00
82a13007c8 chore: there should be less latency now! 2025-09-02 18:46:23 +02:00
a35fd858dc feat: use catppuccin on docs 2025-09-02 18:20:55 +02:00
4f03f002ab feat: use astro starlight instead of fumadocs 2025-09-02 17:39:58 +02:00
d36e590ab6 chore: static 2025-09-02 00:43:26 +02:00
c77d7a16e6 chore: use static export 2025-09-02 00:40:09 +02:00
d656d0f579 fix: docs builds 2025-09-02 00:31:02 +02:00
2896cae2bb feat: docs (#55) 2025-09-02 00:19:50 +02:00
83 changed files with 3055 additions and 1702 deletions

View File

@@ -10,6 +10,12 @@ jobs:
name: Push frontend to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Wait
uses: NathanFirmo/wait-for-other-action@v1.0.4
with:
token: ${{ secrets.GITHUB_TOKEN }}
workflow: 'emojis.yml'
- name: Check out the repo
uses: actions/checkout@v3
@@ -41,7 +47,7 @@ jobs:
mkdir -p apps/web/src/lib/instrumentation/
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
./slack-import-emojis-bin
./slack-import-emojis-bin default
cp emojis.json apps/web/

4
.gitignore vendored
View File

@@ -45,4 +45,6 @@ packages/db/generated/client
*dist
slack-import-emojis/target
**/*/emojis.json
**/*/emojis.json
.idea

View File

@@ -1,8 +0,0 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

View File

@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises';
import { lucia } from '@hctv/auth';
import { getCookie } from 'hono/cookie';
import { getPersonalChannel } from './utils/personalChannel.js';
import { getRedisConnection, prisma, type User } from '@hctv/db';
import { getRedisConnection, prisma, type BotAccount, type BotApiKey, type User } from '@hctv/db';
import uFuzzy from '@leeoniya/ufuzzy';
import { randomString } from './utils/randomString.js';
@@ -29,71 +29,92 @@ app.get('/up', async (c) => {
app.get(
'/ws/:username',
upgradeWebSocket((c) => ({
// https://hono.dev/helpers/websocket
async onOpen(evt, ws) {
const token = getCookie(c, 'auth_session');
const grant = c.req.query('grant');
console.log({
token,
grant,
})
const authHeader = c.req.header('Authorization');
// random checks that actually make sense if you read trust me bro
if (!token && !grant) {
ws.close();
return;
}
if (!token && grant === 'null') {
if (!token && (!grant || grant === 'null') && !authHeader) {
ws.close();
return;
}
let user: User | null = null
let chatUser: ChatUser | null = null;
let personalChannel: any = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
const apiKey = authHeader.substring(7);
const botAccount = await prisma.botApiKey.findUnique({
where: { key: apiKey },
include: { botAccount: true }
});
if (botAccount) {
chatUser = {
id: botAccount.botAccount.id,
username: botAccount.botAccount.slug,
pfpUrl: botAccount.botAccount.pfpUrl,
displayName: botAccount.botAccount.displayName,
isBot: true
};
personalChannel = {
id: botAccount.botAccount.id,
name: botAccount.botAccount.slug
};
}
}
if (!chatUser && token) {
const session = await lucia.validateSession(token);
if (session.user) {
const userChannel = await getPersonalChannel(session.user.id);
if (userChannel) {
chatUser = {
id: session.user.id,
username: userChannel.name,
pfpUrl: session.user.pfpUrl,
isBot: false
};
personalChannel = userChannel;
}
}
}
const dbGrant = await prisma.channel.findFirst({
where: {
obsChatGrantToken: grant,
}
where: { obsChatGrantToken: grant }
});
if (token) {
user = (await lucia.validateSession(token)).user;
const personalChannel = await getPersonalChannel(user!.id);
if (!personalChannel) {
ws.close();
return;
}
ws.personalChannel = personalChannel;
}
if (!user && !dbGrant) {
if (!chatUser && !dbGrant) {
ws.close();
return;
}
const { username } = c.req.param();
if (dbGrant && dbGrant?.name !== username) {
if (dbGrant && dbGrant.name !== username) {
ws.close();
return;
}
ws.targetUsername = username;
ws.user = user;
ws.chatUser = chatUser;
ws.personalChannel = personalChannel;
ws.viewerId = randomString(10);
if (ws.raw) {
ws.raw.targetUsername = username;
// @ts-ignore
ws.raw.user = user;
ws.raw.personalChannel = ws.personalChannel;
ws.raw.chatUser = chatUser;
ws.raw.personalChannel = personalChannel;
}
const redis = getRedisConnection();
const channelKey = `chat:history:${username}`;
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) {
@@ -116,33 +137,34 @@ app.get(
},
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',
})
);
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
if (msg.type === 'message') {
if (!ws.personalChannel) return;
if (!ws.chatUser || !ws.personalChannel) return;
const message = (msg.message as string).trim();
const msgObj = {
user: {
id: ws.user.id,
username: ws.personalChannel.name,
pfpUrl: ws.user.pfpUrl,
id: ws.chatUser.id,
username: ws.chatUser.username,
pfpUrl: ws.chatUser.pfpUrl,
displayName: ws.chatUser.displayName,
isBot: ws.chatUser.isBot || false
},
message,
};
// Save to Redis without the type field to maintain compatibility
const redisObj = {
user: msgObj.user,
message: msgObj.message,
type: 'message',
};
const redisStr = JSON.stringify(redisObj);
const msgStr = JSON.stringify(msgObj);
@@ -238,3 +260,11 @@ const server = serve(
}
);
injectWebSocket(server);
interface ChatUser {
id: string;
username: string;
pfpUrl: string;
displayName?: string;
isBot: boolean;
}

37
apps/docs/.gitignore vendored
View File

@@ -1,28 +1,21 @@
# deps
/node_modules
# build output
dist/
# generated types
.astro/
# generated content
.contentlayer
.content-collections
.source
# dependencies
node_modules/
# test & build
/coverage
/.next/
/out/
/build
*.tsbuildinfo
# misc
.DS_Store
*.pem
/.pnp
.pnp.js
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# others
.env*.local
.vercel
next-env.d.ts
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

4
apps/docs/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
apps/docs/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@@ -1,45 +0,0 @@
# docs
This is a Next.js application generated with
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
Run development server:
```bash
npm run dev
# or
pnpm dev
# or
yarn dev
```
Open http://localhost:3000 with your browser to see the result.
## Explore
In the project, you can see:
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
| Route | Description |
| ------------------------- | ------------------------------------------------------ |
| `app/(home)` | The route group for your landing page and other pages. |
| `app/docs` | The documentation layout and pages. |
| `app/api/search/route.ts` | The Route Handler for search. |
### Fumadocs MDX
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
## Learn More
To learn more about Next.js and Fumadocs, take a look at the following
resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs

View File

@@ -0,0 +1,25 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import mermaid from 'astro-mermaid';
import catppuccin from "@catppuccin/starlight";
// https://astro.build/config
export default defineConfig({
integrations: [
mermaid({
theme: 'base',
autoTheme: true
}),
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" }
}),
]
}),
],
});

View File

@@ -1,10 +0,0 @@
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
};
export default withMDX(config);

View File

@@ -1,31 +1,20 @@
{
"name": "@hctv/docs",
"version": "0.0.0",
"private": true,
"type": "module",
"version": "0.0.1",
"scripts": {
"build": "next build",
"dev": "next dev --turbo -p 3727",
"start": "next start",
"postinstall": "fumadocs-mdx"
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"fumadocs-core": "15.7.7",
"fumadocs-mdx": "11.8.2",
"fumadocs-ui": "15.7.7",
"@astrojs/starlight": "^0.35.2",
"@catppuccin/starlight": "^1.0.2",
"astro": "^5.6.1",
"astro-mermaid": "^1.0.4",
"mermaid": "^11.10.1",
"next": "15.5.2",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.12",
"@types/mdx": "^2.0.13",
"@types/node": "24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2"
"sharp": "^0.34.2"
}
}

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>

After

Width:  |  Height:  |  Size: 696 B

View File

@@ -1,25 +0,0 @@
import {
defineConfig,
defineDocs,
frontmatterSchema,
metaSchema,
} from 'fumadocs-mdx/config';
import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.dev/docs/mdx/collections#define-docs
export const docs = defineDocs({
docs: {
schema: frontmatterSchema,
},
meta: {
schema: metaSchema,
},
});
export default defineConfig({
mdxOptions: {
// MDX options
remarkPlugins: [remarkMdxMermaid],
},
});

View File

@@ -1,8 +0,0 @@
export function GET() {
return new Response('Redirecting...', {
status: 302,
headers: {
Location: '/docs',
},
});
}

View File

@@ -1,7 +0,0 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});

View File

@@ -1,52 +0,0 @@
import { source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getMDXComponents } from '@/mdx-components';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDXContent = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDXContent
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
// @ts-ignore
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

@@ -1,11 +0,0 @@
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions()}>
{children}
</DocsLayout>
);
}

View File

@@ -1,3 +0,0 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';

View File

@@ -1,17 +0,0 @@
import '@/app/global.css';
import { RootProvider } from 'fumadocs-ui/provider';
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
});
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,60 +0,0 @@
'use client';
import { use, useEffect, useId, useState } from 'react';
import { useTheme } from 'next-themes';
export function Mermaid({ chart }: { chart: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return;
return <MermaidContent chart={chart} />;
}
const cache = new Map<string, Promise<unknown>>();
function cachePromise<T>(
key: string,
setPromise: () => Promise<T>,
): Promise<T> {
const cached = cache.get(key);
if (cached) return cached as Promise<T>;
const promise = setPromise();
cache.set(key, promise);
return promise;
}
function MermaidContent({ chart }: { chart: string }) {
const id = useId();
const { resolvedTheme } = useTheme();
const { default: mermaid } = use(
cachePromise('mermaid', () => import('mermaid')),
);
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
fontFamily: 'inherit',
themeCSS: 'margin: 1.5rem auto 0;',
theme: resolvedTheme === 'dark' ? 'dark' : 'default',
});
const { svg, bindFunctions } = use(
cachePromise(`${chart}-${resolvedTheme}`, () => {
return mermaid.render(id, chart.replaceAll('\\n', '\n'));
}),
);
return (
<div
ref={(container) => {
if (container) bindFunctions?.(container);
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}

View File

@@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@@ -3,12 +3,19 @@ title: Chat
description: Chat websocket
---
import { Aside } from '@astrojs/starlight/components';
The chat system is powered by a websocket server. Please read the entire page before implementing anything, as there are some important notes.
## Connection and messages
The websocket server is located at `wss://hctv.srizan.dev/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.
The websocket server is located at `wss://hctv.srizan.dev/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.
<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`
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.

View File

@@ -1,30 +0,0 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
/**
* Shared layout configurations
*
* you can customise layouts individually from:
* Home Layout: app/(home)/layout.tsx
* Docs Layout: app/docs/layout.tsx
*/
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: (
<>
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
aria-label="Logo"
>
<circle cx={12} cy={12} r={12} fill="currentColor" />
</svg>
My App
</>
),
},
// see https://fumadocs.dev/docs/ui/navigation/links
links: [],
};
}

View File

@@ -1,9 +0,0 @@
import { docs } from '@/.source';
import { loader } from 'fumadocs-core/source';
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
export const source = loader({
// it assigns a URL to your pages
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});

View File

@@ -1,12 +0,0 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { Mermaid } from '@/components/mdx/mermaid';
import type { MDXComponents } from 'mdx/types';
// use this function to get MDX components, you will need it for rendering MDX
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
Mermaid,
...components,
};
}

View File

@@ -1,45 +1,5 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/.source": [
"./.source/index.ts"
],
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

0
apps/docs/yarn.lock Normal file
View File

View File

@@ -20,7 +20,7 @@ RUN turbo prune @hctv/web --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat git
RUN apk add --no-cache libc6-compat git vips vips-dev python3 make g++
# Get the commit hash from the builder stage
COPY --from=builder /tmp/commit_hash /tmp/commit_hash
# Read commit hash and set as build arg
@@ -33,6 +33,8 @@ WORKDIR /app
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
RUN cd apps/web && yarn add sharp --platform=linuxmusl --arch=x64
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM \
. /tmp/build_env && \
@@ -42,7 +44,7 @@ RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM \
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache ffmpeg
RUN apk add --no-cache ffmpeg vips vips-dev
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs

View File

@@ -28,7 +28,14 @@ const nextConfig = {
},
{
hostname: 'emoji.slack-edge.com',
}
},
{
hostname: 'cdn.jsdelivr.net',
pathname: '/npm/emoji-datasource-twitter@15.1.2/img/twitter/64/*',
},
{
hostname: 'eoceqrx2r7.ufs.sh'
},
],
minimumCacheTTL: 120,
},
@@ -40,6 +47,7 @@ const nextConfig = {
reactStrictMode: false,
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '../../'),
serverExternalPackages: ['bullmq'],
async rewrites() {
return [
{

View File

@@ -20,6 +20,7 @@
"@hookform/resolvers": "^3.9.1",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@node-rs/argon2": "^2.0.2",
"@omit/react-confirm-dialog": "^1.2.0",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.5",
@@ -46,11 +47,10 @@
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"hls-video-element": "^1.5.0",
"ioredis": "5.7.0",
"lucia": "^3.2.2",
"lucide-react": "^0.473.0",
"media-chrome": "^4.8.0",
"next": "^15.3.4",
"next": "^15.6.0-canary.34",
"next-themes": "^0.4.4",
"node-cron": "^3.0.3",
"nuqs": "^2.4.3",
@@ -64,7 +64,7 @@
"rehype-sanitize": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"sharp": "^0.34.2",
"sharp": "^0.34.3",
"sonner": "^1.4.41",
"swr": "^2.3.0",
"tailwind-merge": "^2.2.2",

View File

@@ -16,4 +16,5 @@ Sentry.init({
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
enabled: process.env.NODE_ENV === 'production',
});

View File

@@ -1,18 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@@ -0,0 +1,124 @@
import { validateRequest } from "@/lib/auth/validate";
import { prisma } from "@hctv/db";
import { NextRequest } from "next/server";
import { z } from "zod";
type Params = Promise<{ slug: string }>;
export async function POST(request: NextRequest, segmentData: { params: Params }) {
const { slug } = await segmentData.params;
const { user } = await validateRequest();
if (!user) {
return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { status: 401 });
}
const bodySchema = z.object({
action: z.enum(['revoke', 'regenerate', 'create']),
name: z.string().min(3, 'Name must be at least 3 characters long').max(50, 'Name must be at most 50 characters long'),
});
const body = await request.json();
const parsedBody = bodySchema.safeParse(body);
if (!parsedBody.success) {
return new Response(JSON.stringify({ success: false, error: parsedBody.error.errors.map(e => e.message).join(', ') }), { status: 400 });
}
const { action, name } = parsedBody.data;
if (action === 'create') {
const exists = await prisma.botApiKey.findFirst({
where: {
name,
botAccount: {
ownerId: user.id,
slug,
}
}
});
if (exists) {
return new Response(JSON.stringify({ success: false, error: 'API Key with this name already exists' }), { status: 400 });
}
const newKey = await prisma.botApiKey.create({
data: {
name,
botAccount: {
connect: {
ownerId: user.id,
slug,
}
},
key: generateApiKey(),
}
});
return new Response(JSON.stringify({ success: true, apiKey: newKey.key, id: newKey.id }));
}
if (action === 'regenerate') {
const existingKey = await prisma.botApiKey.findFirst({
where: {
name,
botAccount: {
ownerId: user.id,
slug,
}
}
});
if (!existingKey) {
return new Response(JSON.stringify({ success: false, error: 'API Key not found' }), { status: 404 });
}
const newKey = generateApiKey();
await prisma.botApiKey.update({
where: { id: existingKey.id },
data: { key: newKey },
});
return new Response(JSON.stringify({ success: true, apiKey: newKey, id: existingKey.id }));
}
if (action === 'revoke') {
const existingKey = await prisma.botApiKey.findFirst({
where: {
name,
botAccount: {
ownerId: user.id,
slug,
}
}
});
if (!existingKey) {
return new Response(JSON.stringify({ success: false, error: 'API Key not found' }), { status: 404 });
}
await prisma.botApiKey.delete({
where: { id: existingKey.id },
});
return new Response(JSON.stringify({ success: true }));
}
return new Response(JSON.stringify({ success: false, error: 'Invalid action' }), { status: 400 });
}
export async function GET(request: NextRequest, segmentData: { params: Params }) {
const { slug } = await segmentData.params;
const { user } = await validateRequest();
if (!user) {
return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), { status: 401 });
}
const apiKeys = await prisma.botApiKey.findMany({
where: {
botAccount: {
ownerId: user.id,
slug,
}
},
select: {
id: true,
name: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
}
});
return new Response(JSON.stringify({ success: true, apiKeys }));
}
function generateApiKey() {
const uuid = crypto.randomUUID().replace(/-/g, '');
return `hctvb_${uuid}`;
}

View File

@@ -103,7 +103,7 @@ export async function POST(request: NextRequest) {
});
await queue.add(`newFollow:${username}`, {
text: `You started following \`${username}\`!\n_Stream notifications are enabled by default. If you want to disable them, you can do so in \`Profile > Notifications\`._`,
text: `You started following \`${username}\`!\n_Stream notifications are disabled by default. If you want to enable them, you can do so in \`Profile > Notifications\`._`,
channel: user.slack_id,
});
}

View File

@@ -0,0 +1,221 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { fetcher } from '@/lib/services/swr';
import { useConfirm } from '@omit/react-confirm-dialog';
import { Plus, RefreshCcw, Trash } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
export function ApiKeys({ slug }: { slug: string }) {
const confirm = useConfirm();
const [newApiKeyName, setNewApiKeyName] = useState('');
const { data, error, isLoading, mutate } = useSWR<GetResponse>(
`/api/settings/bot/${slug}/apiKey`,
fetcher
);
const { trigger } = useSWRMutation(`/api/settings/bot/${slug}/apiKey`, createApiKey);
const apiKeyCreate = () => {
if (newApiKeyName.trim().length < 3) {
toast.error('API Key name must be at least 3 characters long');
return;
}
if (newApiKeyName.trim().length > 50) {
toast.error('API Key name must be at most 50 characters long');
return;
}
trigger({ action: 'create', name: newApiKeyName }).then(
async (res: PostResponse) => {
if (res.success) {
setNewApiKeyName('');
await navigator.clipboard
.writeText(res.apiKey || '')
.then(() => toast.success('API key copied to clipboard'))
.catch(() => {
alert('Failed to copy API key to clipboard, here it is: ' + res.apiKey);
});
await mutate();
} else {
toast.error(res.error || 'Error creating API key');
}
}
);
};
return (
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>Manage your API keys</CardDescription>
</CardHeader>
<CardContent>
{isLoading && <ApiKeysSkeleton />}
{error && <p>Error loading API keys</p>}
{data && !data.success && <p>Error: Could not fetch API keys</p>}
{data && (
<div className="flex">
<Input
placeholder="New API Key Name"
className="flex-1 mr-2"
value={newApiKeyName}
onChange={(e) => setNewApiKeyName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
apiKeyCreate();
}
}}
/>
<Button
size="icon"
onClick={apiKeyCreate}
>
<Plus />
</Button>
</div>
)}
{data && data.success && data.apiKeys.length === 0 && <p>No API keys found</p>}
{data && data.success && data.apiKeys.length > 0 && (
<ul className="space-y-2 pt-4">
{data.apiKeys.map((key) => (
<li
key={key.id}
className="flex items-center justify-between p-3 bg-mantle/50 rounded-md"
>
<div className="flex-1">
<strong className="text-sm font-medium">{key.name}</strong>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
const confirmation = await confirm({
title: 'Regenerate API Key',
description:
'Are you sure you want to regenerate this API key? The old key will stop working.',
confirmText: 'Regenerate',
cancelText: 'Cancel',
});
if (!confirmation) return;
trigger({ action: 'regenerate', name: key.name }).then(
async (res: PostResponse) => {
if (res.success) {
await navigator.clipboard
.writeText(res.apiKey || '')
.then(() => {
toast.success('API key copied to clipboard');
})
.catch(() => {
alert(
'Failed to copy API key to clipboard, here it is: ' + res.apiKey
);
});
mutate();
} else {
toast.error(res.error || 'Error regenerating API key');
}
}
);
}}
>
<RefreshCcw className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={async () => {
const confirmation = await confirm({
title: 'Revoke API Key',
description:
'Are you sure you want to revoke this API key? This action cannot be undone.',
confirmText: 'Revoke',
cancelText: 'Cancel',
});
if (!confirmation) return;
trigger({ action: 'revoke', name: key.name }).then(
async (res: PostResponse) => {
if (res.success) {
await mutate();
} else {
toast.error(res.error || 'Error revoking API key');
}
}
);
}}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
function ApiKeysSkeleton() {
return (
<>
<div className='flex'>
<Skeleton className='h-10 flex-1 mr-2' />
<Skeleton className='h-10 w-10' />
</div>
<div className='space-y-2 pt-4'>
{[1, 2, 3].map((i) => (
<div key={i} className='flex items-center justify-between p-3 bg-mantle/50 rounded-md'>
<div className='flex-1'>
<Skeleton className='h-4 w-1/2' />
</div>
<div className='flex items-center space-x-2'>
<Skeleton className='h-8 w-8' />
<Skeleton className='h-8 w-8' />
</div>
</div>
))}
</div>
</>
)
}
async function createApiKey(url: string, { arg }: { arg: PostRequest }) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(arg),
});
return res.json();
}
interface GetResponse {
success: boolean;
apiKeys: Array<{ id: string; name: string; createdAt: string }>;
}
interface PostRequest {
action: 'revoke' | 'regenerate' | 'create';
name: string;
}
interface PostResponse {
success: boolean;
apiKey?: string;
id?: string;
error?: string;
}

View File

@@ -0,0 +1,62 @@
'use client';
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { editBot } from '@/lib/form/actions';
import { BotAccount } from '@hctv/db';
import { useRouter } from 'next/navigation';
export function GeneralSettings(props: BotAccount) {
const router = useRouter();
return (
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>Edit your bot settings!</CardDescription>
</CardHeader>
<CardContent>
<UniversalForm
fields={[
{
name: 'from',
type: 'hidden',
value: props.id,
required: true,
},
{
name: 'name',
type: 'text',
label: 'Bot Name',
placeholder: 'Enter bot name',
required: true,
value: props.displayName,
},
{
name: 'slug',
type: 'text',
label: 'Bot Slug',
placeholder: 'Enter bot slug',
required: true,
value: props.slug
},
{
name: 'description',
type: 'textarea',
label: 'Description',
placeholder: 'Enter bot description',
value: props.description,
textArea: true,
},
]}
schemaName={'editBot'}
action={editBot}
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,45 @@
import { getBotBySlug } from '@/lib/db/resolve';
import { validateRequest } from '@/lib/auth/validate';
import { redirect } from 'next/navigation';
import Image from 'next/image';
import { GeneralSettings } from '@/app/(ui)/(protected)/settings/bot/[slug]/gensettings';
import { ApiKeys } from '@/app/(ui)/(protected)/settings/bot/[slug]/apikeys';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { user } = await validateRequest();
const { slug } = await params;
const bot = await getBotBySlug(slug);
if (!bot || bot.ownerId !== user?.id) {
redirect('/settings/bot');
}
return (
<div className={'container mx-auto py-6 space-y-6'}>
<Link href={'/settings/bot'} className="text-sm text-muted-foreground hover:underline flex items-center gap-2">
<ArrowLeft className='size-4' /> Back to Bot Accounts
</Link>
<div className="flex items-center justify-between">
<div className={'flex items-center space-x-4'}>
<Image
src={bot.pfpUrl}
alt={'Bot Avatar'}
width={48}
height={48}
className="rounded-full"
/>
<div className={'flex flex-col'}>
<h1 className="text-3xl font-bold tracking-tight">{bot.displayName}</h1>
<p className="text-muted-foreground">Manage your bot account settings</p>
</div>
</div>
</div>
<div className="flex w-full gap-4 flex-col md:flex-row *:w-1/2">
<GeneralSettings {...bot} />
<ApiKeys slug={slug} />
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
import { createBot } from '@/lib/form/actions';
import { Bot } from 'lucide-react';
import { useRouter } from 'next/navigation';
export default function Page() {
const router = useRouter();
return (
<div className="min-h-screen bg-background">
<div className="flex h-full w-full flex-col items-center justify-center px-4 py-12">
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary shadow-lg">
<Bot className="h-8 w-8 text-primary-foreground" />
</div>
<h1 className="mb-2 text-3xl font-bold text-foreground">Create Bot Account</h1>
<p className="text-muted-foreground max-w-xl">
Create an automated bot account to provide custom functionality for your community.
</p>
</div>
<div className="w-full max-w-md bg-card rounded-xl p-8 border border-border">
<UniversalForm
fields={[
{
name: 'name',
type: 'text',
label: 'Bot Name',
placeholder: 'Enter bot name',
required: true,
},
{
name: 'slug',
type: 'text',
label: 'Bot Slug',
placeholder: 'Enter bot slug',
required: true,
},
{
name: 'description',
type: 'textarea',
label: 'Description',
placeholder: 'Enter bot description',
},
]}
schemaName={'createBot'}
action={createBot}
onActionComplete={(res) => {
router.push(`/settings/bot/${res.slug}`);
}}
/>
</div>
{/*
<p className="mt-6 text-sm text-muted-foreground text-center max-w-md">
Your bot will be created with chat permissions. You can configure advanced settings and
permissions after creation.
</p>
*/}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import Link from 'next/link';
import { Plus, Bot, Calendar, Hash } from 'lucide-react';
import { redirect } from 'next/navigation';
import Image from 'next/image';
export default async function Page() {
const { user } = await validateRequest();
if (!user) {
redirect('/');
}
const bots = await prisma.user.findFirst({
where: { id: user.id },
select: {
botAccounts: true,
},
});
return (
<div className="container mx-auto py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Bot Accounts</h1>
<p className="text-muted-foreground">Manage your automated bot accounts</p>
</div>
<Link href="/settings/bot/create">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Bot
</Button>
</Link>
</div>
<Separator />
{bots?.botAccounts.length ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{bots.botAccounts.map((bot) => (
<Link href={`/settings/bot/${bot.slug}`} key={bot.id}>
<Card key={bot.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center space-x-2">
<Image src={bot.pfpUrl} alt={'Bot Avatar'} width={32} height={32} className="rounded-full" />
<CardTitle className="text-lg">{bot.displayName}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Hash className="h-4 w-4" />
<span>{bot.slug}</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>{new Date(bot.createdAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="text-center py-8">
<CardContent className="space-y-4">
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<CardTitle>No bot accounts yet</CardTitle>
<CardDescription className="mt-2">
Get started by creating your first bot account
</CardDescription>
</div>
<Link href="/settings/bot/create">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Your First Bot
</Button>
</Link>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -61,6 +61,7 @@ interface ChannelSettingsClientProps {
streamKey: StreamKey | null;
followers: (Follow & { user: { id: string; slack_id: string } })[];
followerPersonalChannels: (Channel | null)[];
is247: boolean;
};
isOwner: boolean;
currentUser: User;
@@ -149,7 +150,7 @@ export default function ChannelSettingsClient({
value={channel.name}
onSelect={(value) => {
if (value === 'create') {
router.push(`/create`);
router.push(`/settings/channel/create`);
} else {
router.push(`/settings/channel/${value}?tab=${selTab}`);
}
@@ -306,6 +307,27 @@ export default function ChannelSettingsClient({
</div>
),
},
{
name: 'is247',
value: channel.is247,
component: ({ field }) => (
<div className="flex items-center justify-between mt-2">
<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.
</p>
</div>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
<input type="hidden" {...field} value={field.value ? 'true' : 'false'} />
</div>
),
}
]}
schemaName="updateChannelSettings"
action={updateChannelSettings}

View File

@@ -32,8 +32,7 @@ function CreateChannelPage() {
schemaName="createChannel"
action={createChannel}
onActionComplete={(r) => {
// @ts-expect-error
const channelName = r?.channel;
const channelName = r.channel;
if (channelName) {
router.push(`/${channelName}`);
}

View File

@@ -16,6 +16,7 @@ import { extractRouterConfig } from 'uploadthing/server';
import { ourFileRouter } from '@/lib/services/uploadthing/fileRouter';
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import SonnerNewVersion from '@/components/app/SonnerNewVersion/SonnerNewVersion';
import ConfirmDialogProvider from '@/lib/providers/ConfirmProvider';
const inter = Inter({ subsets: ['latin'] });
@@ -44,20 +45,22 @@ export default async function RootLayout({
<NextSSRPlugin
routerConfig={extractRouterConfig(ourFileRouter)}
/>
<NuqsAdapter>
<SidebarProvider>
<StreamInfoProvider>
{/* this promise is ugly but i'm lazy to fix the type errors */}
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
<div className="flex flex-1 pt-16">
{/* pt-16 for navbar height */}
<Sidebar className="pt-16" />
<main className="flex-1 overflow-auto">{children}</main>
</div>
<Toaster />
</StreamInfoProvider>
</SidebarProvider>
</NuqsAdapter>
<ConfirmDialogProvider>
<NuqsAdapter>
<SidebarProvider>
<StreamInfoProvider>
{/* this promise is ugly but i'm lazy to fix the type errors */}
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
<div className="flex flex-1 pt-16">
{/* pt-16 for navbar height */}
<Sidebar className="pt-16" />
<main className="flex-1 overflow-auto">{children}</main>
</div>
<Toaster />
</StreamInfoProvider>
</SidebarProvider>
</NuqsAdapter>
</ConfirmDialogProvider>
</ThemeProvider>
</SessionProvider>
</body>

View File

@@ -119,6 +119,7 @@
body {
@apply bg-background text-foreground;
}
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
}

View File

@@ -313,6 +313,8 @@ export interface User {
id: string;
username: string;
pfpUrl: string;
isBot: boolean;
displayName?: string;
}
interface Props {

View File

@@ -1,11 +1,8 @@
import { User } from './ChatPanel';
import React from 'react';
import Image from 'next/image';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Bot } from 'lucide-react';
export function Message({ user, message, type, emojiMap }: MessageProps) {
if (type === 'systemMsg') {
@@ -18,12 +15,18 @@ export function Message({ user, message, type, emojiMap }: MessageProps) {
return (
<div className="flex">
<div
lang="en"
className="max-w-full break-all whitespace-pre-wrap hyphens-auto"
>
<p>
<span className="font-bold mr-2">{user?.username}</span>
<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>
<EmojiRenderer text={message} emojiMap={emojiMap} />
</p>
</div>
@@ -47,13 +50,21 @@ export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
return (
<Tooltip key={index} delayDuration={250}>
<TooltipTrigger>
<span key={index} className="inline-block align-middle" style={{ height: '1.2em' }}>
<Image src={emojiUrl} alt={part} width={20} height={20} className="inline-block" />
<span
key={index}
className="inline-block align-middle"
style={{ height: '1.2em' }}
>
<Image
src={emojiUrl}
alt={part}
width={20}
height={20}
className="inline-block"
/>
</span>
</TooltipTrigger>
<TooltipContent>
{part}
</TooltipContent>
<TooltipContent>{part}</TooltipContent>
</Tooltip>
);
}

View File

@@ -51,9 +51,22 @@ export default function Navbar(props: Props) {
<Link href={`/settings/follows`}>
<DropdownMenuItem className="cursor-pointer">Follows</DropdownMenuItem>
</Link>
<Link href={`/create`}>
<Link href={`/settings/channel/create`}>
<DropdownMenuItem className="cursor-pointer">Create channel</DropdownMenuItem>
</Link>
<Link href={`/settings/bot`}>
<DropdownMenuItem className="cursor-pointer">Bot accounts</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<Link href={'https://docs.hctv.srizan.dev'} target="_blank" rel="noreferrer">
<DropdownMenuItem className="cursor-pointer">API Docs</DropdownMenuItem>
</Link>
<Link href={'https://github.com/SrIzan10/hctv'} target="_blank" rel="noreferrer">
<DropdownMenuItem className="cursor-pointer">Github</DropdownMenuItem>
</Link>
<Link href={'https://github.com/sponsors/SrIzan10'} target="_blank" rel="noreferrer">
<DropdownMenuItem className="cursor-pointer">Sponsor</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>

View File

@@ -10,15 +10,15 @@ import {
MediaSeekForwardButton,
MediaMuteButton,
MediaVolumeRange,
MediaFullscreenButton
MediaFullscreenButton,
} from 'media-chrome/react';
import HlsVideo from 'hls-video-element/react'
import HlsVideo from 'hls-video-element/react';
export default function StreamPlayer() {
const { username } = useParams();
return (
<MediaController className='w-full aspect-video'>
<MediaController className="w-full aspect-video">
<HlsVideo
src={`/api/rtmp/hls/${username}.m3u8`}
slot="media"
@@ -26,31 +26,35 @@ export default function StreamPlayer() {
autoplay
config={{
lowLatencyMode: true,
liveSyncDurationCount: 2,
liveMaxLatencyDurationCount: 4,
liveSyncDurationCount: 1,
liveMaxLatencyDurationCount: 2,
liveDurationInfinity: true,
enableWorker: true,
backBufferLength: 2,
startLevel: 0,
maxBufferLength: 4,
maxMaxBufferLength: 8,
backBufferLength: 1,
startLevel: -1,
maxBufferLength: 2,
maxMaxBufferLength: 4,
startFragPrefetch: true,
testBandwidth: false,
progressive: true,
maxBufferSize: 30 * 1000 * 1000,
maxBufferHole: 0.3,
highBufferWatchdogPeriod: 1,
nudgeOffset: 0.05,
nudgeMaxRetry: 2,
manifestLoadingTimeOut: 5000,
manifestLoadingMaxRetry: 2,
levelLoadingTimeOut: 5000,
fragLoadingTimeOut: 10000,
progressive: false,
maxBufferSize: 10 * 1000 * 1000,
maxBufferHole: 0.1,
highBufferWatchdogPeriod: 0.5,
nudgeOffset: 0.01,
nudgeMaxRetry: 3,
manifestLoadingTimeOut: 3000,
manifestLoadingMaxRetry: 3,
levelLoadingTimeOut: 3000,
fragLoadingTimeOut: 5000,
debug: process.env.NODE_ENV === 'development',
liveSyncDuration: 1,
liveMaxLatencyDuration: 3,
maxLiveSyncPlaybackRate: 1.5,
liveBackBufferLength: 0,
}}
/>
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
<MediaControlBar className='w-full px-2'>
<MediaControlBar className="w-full px-2">
<div className="flex items-center gap-2">
<MediaPlayButton />
<MediaMuteButton />

View File

@@ -19,13 +19,18 @@ import React from 'react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from '@/lib/form/zod';
import {
createBotSchema,
createChannelSchema, editBotSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema
} from '@/lib/form/zod';
export const schemaDb = [
{ name: 'streamInfoEdit', zod: streamInfoEditSchema },
{ name: 'onboard', zod: onboardSchema },
{ name: 'createChannel', zod: createChannelSchema },
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
{ name: 'createBot', zod: createBotSchema },
{ name: 'editBot', zod: editBotSchema }
] as const;
export function UniversalForm<T extends z.ZodType>({
@@ -39,7 +44,7 @@ export function UniversalForm<T extends z.ZodType>({
otherSubmitButton,
submitButtonDivClassname,
}: UniversalFormProps<T>) {
// @ts-ignore idk why this error is happening, first apprearing on the react 19 update.
// @ts-expect-error - idk
const [state, formAction] = useActionState<{ success: boolean; error?: string }>(action, null);
const schema = schemaDb.find((s) => s.name === schemaName)?.zod;
@@ -56,9 +61,11 @@ export function UniversalForm<T extends z.ZodType>({
return { ...values, ...defaultValues };
}, [fields, defaultValues]);
const form = useForm<z.infer<T>>({
resolver: zodResolver(schema),
defaultValues: initialValues as z.infer<T>,
type FormData = z.infer<T>;
const form = useForm<FormData>({
resolver: zodResolver(schema as any),
defaultValues: initialValues as FormData,
});
React.useEffect(() => {
@@ -77,10 +84,10 @@ export function UniversalForm<T extends z.ZodType>({
<FormField
key={field.name}
control={form.control}
name={field.name as Path<z.infer<T>>}
name={field.name as Path<FormData>}
render={({ field: formField }) => (
<FormItem>
{field.type !== 'hidden' && <FormLabel>{field.label}</FormLabel>}
{(field.type !== 'hidden' || field.label) && <FormLabel>{field.label}</FormLabel>}
<FormControl>
{field.component ? (
field.component({ field: formField, ...field.componentProps })

View File

@@ -5,7 +5,7 @@ import { schemaDb } from './UniversalForm';
export type FormFieldConfig = {
name: string;
label: string;
label?: string;
type?: HTMLInputTypeAttribute;
placeholder?: string;
description?: string;
@@ -14,13 +14,14 @@ export type FormFieldConfig = {
textAreaRows?: number;
component?: (props: { field: ControllerRenderProps<any, any> } & any) => React.ReactNode;
componentProps?: Record<string, any>;
required?: boolean;
};
export type UniversalFormProps<T extends z.ZodType> = {
fields: FormFieldConfig[];
schemaName: (typeof schemaDb)[number]['name'];
action: (prev: any, formData: FormData) => void;
onActionComplete?: (result: unknown) => void;
onActionComplete?: (result: any) => void;
defaultValues?: Partial<z.infer<T>>;
submitText?: string;
submitClassname?: string;

View File

@@ -14,6 +14,7 @@ Sentry.init({
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
enabled: process.env.NODE_ENV === 'production'
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

View File

@@ -1,3 +1,7 @@
import * as Sentry from "@sentry/nextjs";
export const onRequestError = Sentry.captureRequestError;
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await (await import('@/lib/instrumentation/streamInfo')).default();
@@ -40,4 +44,21 @@ export async function register() {
await viewerCountSync();
}, 2000);
}
process.env.NODE_ENV === 'production' && Sentry.init({
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
integrations: [
Sentry.extraErrorDataIntegration(),
],
});
}

View File

@@ -1,4 +1,5 @@
import { prisma } from '@hctv/db';
import {Prisma, prisma} from '@hctv/db';
import {validateRequest} from "@/lib/auth/validate";
export async function resolveChannelNameId(channelName: string) {
const channel = await prisma.channel.findUnique({
@@ -28,4 +29,14 @@ export async function resolveUserPersonalChannel(userId: string) {
}
return channel;
}
export async function getBotBySlug(slug: string) {
const bot = await prisma.botAccount.findFirst({
where: {
slug,
},
});
return bot;
}

View File

@@ -4,7 +4,10 @@ import { revalidatePath } from 'next/cache';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import zodVerify from '../zodVerify';
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from './zod';
import {
createBotSchema,
createChannelSchema, editBotSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema
} from './zod';
import { initializeStreamInfo } from '../instrumentation/streamInfo';
import { resolveFollowedChannels, resolveStreamInfo, resolveUserFromPersonalChannelName } from '../auth/resolve';
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
@@ -202,6 +205,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
data: {
description: zod.data.description || undefined,
pfpUrl: zod.data.pfpUrl,
is247: zod.data.is247,
},
});
@@ -352,4 +356,76 @@ export async function deleteChannel(channelId: string) {
});
return { success: true }; */
}
export async function createBot(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(createBotSchema, formData);
if (!zod.success) {
return zod;
}
const botExists = await prisma.botAccount.findFirst({
where: { slug: zod.data.slug },
});
if (botExists) {
return { success: false, error: 'Bot slug already exists' };
}
const createdBot = await prisma.botAccount.create({
data: {
displayName: zod.data.name,
slug: zod.data.slug,
ownerId: user.id,
description: zod.data.description,
pfpUrl: await genIdenticonUpload(zod.data.slug, 'botpfp'),
}
});
return { success: true, slug: createdBot.slug }
}
export async function editBot(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(editBotSchema, formData);
if (!zod.success) {
return zod;
}
const bot = await prisma.botAccount.findUnique({
where: { id: zod.data.from },
});
if (!bot) {
return { success: false, error: 'Bot not found' };
}
if (bot.ownerId !== user.id) {
return { success: false, error: 'Unauthorized' };
}
if (bot.slug !== zod.data.slug) {
const botExists = await prisma.botAccount.findFirst({
where: { slug: zod.data.slug },
});
if (botExists) {
return { success: false, error: 'Bot slug already exists' };
}
}
const updatedBot = await prisma.botAccount.update({
where: { id: zod.data.from },
data: {
displayName: zod.data.name,
slug: zod.data.slug,
description: zod.data.description,
}
});
revalidatePath(`/settings/bot/${updatedBot.slug}`);
return { success: true, slug: updatedBot.slug }
}

View File

@@ -1,9 +1,20 @@
import { z } from 'zod';
const disallowedUsernames = [
'admin',
'administrator',
'settings',
'create',
// i hope this doesn't age well tbh
'zrl',
];
const username = z
.string()
.min(1)
.regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' });
.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',
});
export const streamInfoEditSchema = z.object({
username: z.string().min(1),
@@ -24,4 +35,17 @@ export const updateChannelSettingsSchema = z.object({
channelId: z.string().min(1),
pfpUrl: z.string(),
description: z.string().min(1).max(500),
});
is247: z.boolean(),
});
export const createBotSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
slug: username.refine((val) => val !== 'settings', { message: 'This slug is reserved' }),
description: z.string().max(300).optional(),
});
export const editBotSchema = createBotSchema.and(
z.object({
from: z.string().min(1),
})
);

View File

@@ -102,6 +102,7 @@ export async function syncStream() {
if (stream.active) {
const existingStream = await prisma.streamInfo.findUnique({
where: { username: stream.name },
include: { channel: true },
});
if (existingStream && !existingStream.isLive) {
@@ -125,12 +126,14 @@ export async function syncStream() {
const queue = getNotificationQueue();
queue.add(`streamStartChannel:${existingStream.username}`, {
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>`,
channel: process.env.NOTIFICATION_CHANNEL_ID!,
unfurl_links: true,
});
if (existingStream.enableNotifications) {
if (!existingStream.channel.is247) {
queue.add(`streamStartChannel:${existingStream.username}`, {
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${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://hctv.srizan.dev/${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\`._`,

View File

@@ -0,0 +1,21 @@
'use client';
import {
ConfirmDialogProvider as BaseConfirmDialogProvider,
ConfirmOptions,
} from '@omit/react-confirm-dialog';
interface Props {
children: React.ReactNode;
defaultOptions?: ConfirmOptions;
}
export const ConfirmDialogProvider = ({ children, defaultOptions }: Props) => {
return (
<BaseConfirmDialogProvider defaultOptions={defaultOptions}>
{children}
</BaseConfirmDialogProvider>
);
};
export default ConfirmDialogProvider;

View File

@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{

View File

@@ -29,7 +29,7 @@ rtmp {
hls_type live;
hls_path /dev/shm/hls;
hls_fragment 2s;
hls_playlist_length 20s;
hls_playlist_length 10s;
hls_cleanup on;
hls_fragment_naming timestamp;

View File

@@ -20,6 +20,7 @@
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0"
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.4.4"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Channel" ADD COLUMN "is247" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "BotAccounts" (
"id" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"pfpUrl" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
CONSTRAINT "BotAccounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"botAccountId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BotAccounts_slug_key" ON "BotAccounts"("slug");
-- CreateIndex
CREATE INDEX "BotAccounts_channelId_idx" ON "BotAccounts"("channelId");
-- CreateIndex
CREATE INDEX "BotAccounts_slug_idx" ON "BotAccounts"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key");
-- CreateIndex
CREATE INDEX "ApiKey_botAccountId_idx" ON "ApiKey"("botAccountId");
-- AddForeignKey
ALTER TABLE "BotAccounts" ADD CONSTRAINT "BotAccounts_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_botAccountId_fkey" FOREIGN KEY ("botAccountId") REFERENCES "BotAccounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,22 @@
/*
Warnings:
- You are about to drop the column `channelId` on the `BotAccounts` table. All the data in the column will be lost.
- Added the required column `ownerId` to the `BotAccounts` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "BotAccounts" DROP CONSTRAINT "BotAccounts_channelId_fkey";
-- DropIndex
DROP INDEX "BotAccounts_channelId_idx";
-- AlterTable
ALTER TABLE "BotAccounts" DROP COLUMN "channelId",
ADD COLUMN "ownerId" TEXT NOT NULL;
-- CreateIndex
CREATE INDEX "BotAccounts_ownerId_idx" ON "BotAccounts"("ownerId");
-- AddForeignKey
ALTER TABLE "BotAccounts" ADD CONSTRAINT "BotAccounts_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- Added the required column `updatedAt` to the `BotAccounts` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "BotAccounts" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;

View File

@@ -0,0 +1,43 @@
/*
Warnings:
- You are about to drop the `BotAccounts` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_botAccountId_fkey";
-- DropForeignKey
ALTER TABLE "BotAccounts" DROP CONSTRAINT "BotAccounts_ownerId_fkey";
-- DropTable
DROP TABLE "BotAccounts";
-- CreateTable
CREATE TABLE "BotAccount" (
"id" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"pfpUrl" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BotAccount_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BotAccount_slug_key" ON "BotAccount"("slug");
-- CreateIndex
CREATE INDEX "BotAccount_ownerId_idx" ON "BotAccount"("ownerId");
-- CreateIndex
CREATE INDEX "BotAccount_slug_idx" ON "BotAccount"("slug");
-- AddForeignKey
ALTER TABLE "BotAccount" ADD CONSTRAINT "BotAccount_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_botAccountId_fkey" FOREIGN KEY ("botAccountId") REFERENCES "BotAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BotAccount" ALTER COLUMN "description" SET DEFAULT 'A hctv bot account';

View File

@@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the `ApiKey` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_botAccountId_fkey";
-- DropTable
DROP TABLE "ApiKey";
-- CreateTable
CREATE TABLE "BotApiKey" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"botAccountId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BotApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BotApiKey_key_key" ON "BotApiKey"("key");
-- CreateIndex
CREATE INDEX "BotApiKey_botAccountId_idx" ON "BotApiKey"("botAccountId");
-- AddForeignKey
ALTER TABLE "BotApiKey" ADD CONSTRAINT "BotApiKey_botAccountId_fkey" FOREIGN KEY ("botAccountId") REFERENCES "BotAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `name` to the `BotApiKey` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "BotApiKey" ADD COLUMN "name" TEXT NOT NULL;

View File

@@ -5,54 +5,56 @@
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
output = "../generated/client"
provider = "prisma-client-js"
output = "../generated/client"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DATABASE_DIRECT_URL")
}
model User {
id String @id @default(cuid())
slack_id String
pfpUrl String
hasOnboarded Boolean @default(false)
id String @id @default(cuid())
slack_id String
pfpUrl String
hasOnboarded Boolean @default(false)
personalChannel Channel? @relation("PersonalChannel", fields: [personalChannelId], references: [id])
personalChannelId String? @unique
personalChannel Channel? @relation("PersonalChannel", fields: [personalChannelId], references: [id])
personalChannelId String? @unique
ownedChannels Channel[] @relation("ChannelOwner")
managedChannels Channel[] @relation("ChannelManagers")
sessions Session[]
streams StreamInfo[]
followers Follow[] @relation("UserFollows")
ownedChannels Channel[] @relation("ChannelOwner")
managedChannels Channel[] @relation("ChannelManagers")
sessions Session[]
streams StreamInfo[]
followers Follow[] @relation("UserFollows")
botAccounts BotAccount[]
@@index([personalChannelId])
}
model Channel {
id String @id @default(cuid())
name String @unique
id String @id @default(cuid())
name String @unique
description String @default("A hctv channel")
pfpUrl String
pfpUrl String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
personalFor User? @relation("PersonalChannel")
owner User @relation("ChannelOwner", fields: [ownerId], references: [id])
ownerId String
managers User[] @relation("ChannelManagers")
streamInfo StreamInfo[]
followers Follow[] @relation("ChannelFollowers")
streamKey StreamKey?
obsChatGrantToken String @unique @default(cuid())
owner User @relation("ChannelOwner", fields: [ownerId], references: [id])
ownerId String
managers User[] @relation("ChannelManagers")
streamInfo StreamInfo[]
followers Follow[] @relation("ChannelFollowers")
streamKey StreamKey?
obsChatGrantToken String @unique @default(cuid())
is247 Boolean @default(false)
@@index([ownerId])
}
@@ -64,20 +66,20 @@ model Session {
}
model StreamInfo {
id String @id @default(cuid())
username String @unique
id String @id @default(cuid())
username String @unique
title String
thumbnail String
viewers Int
category String
startedAt DateTime
isLive Boolean
channelId String
channel Channel @relation(fields: [channelId], references: [id])
ownedBy User @relation(fields: [userId], references: [id])
userId String
ownedBy User @relation(fields: [userId], references: [id])
userId String
enableNotifications Boolean @default(true)
@@ -87,13 +89,13 @@ model StreamInfo {
model Follow {
id String @id @default(cuid())
createdAt DateTime @default(now())
user User @relation("UserFollows", fields: [userId], references: [id], onDelete: Cascade)
userId String
channel Channel @relation("ChannelFollowers", fields: [channelId], references: [id], onDelete: Cascade)
user User @relation("UserFollows", fields: [userId], references: [id], onDelete: Cascade)
userId String
channel Channel @relation("ChannelFollowers", fields: [channelId], references: [id], onDelete: Cascade)
channelId String
notifyStream Boolean @default(false)
@@unique([userId, channelId])
@@ -102,9 +104,37 @@ model Follow {
}
model StreamKey {
id String @id @default(cuid())
key String @unique
id String @id @default(cuid())
key String @unique
channelId String @unique
channel Channel @relation(fields: [channelId], references: [id])
}
channelId String @unique
channel Channel @relation(fields: [channelId], references: [id])
}
model BotAccount {
id String @id @default(cuid())
displayName String
slug String @unique
description String @default("A hctv bot account")
pfpUrl String
owner User @relation(fields: [ownerId], references: [id])
ownerId String
apiKeys BotApiKey[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([ownerId])
@@index([slug])
}
model BotApiKey {
id String @id @default(cuid())
name String
key String @unique
botAccount BotAccount @relation(fields: [botAccountId], references: [id], onDelete: Cascade)
botAccountId String
createdAt DateTime @default(now())
@@index([botAccountId])
}

View File

@@ -249,12 +249,15 @@ interface ModifiedWSContext extends WSContext<ModifiedWebSocket> {
user?: any;
personalChannel?: any;
viewerId?: string;
botUsername?: string;
chatUser?: ChatUser | null;
}
export interface ModifiedWebSocket extends WebSocket {
targetUsername?: string;
user?: User;
personalChannel?: Channel;
chatUser?: ChatUser | null;
}
interface CloseEventInit extends EventInit {
@@ -262,3 +265,11 @@ interface CloseEventInit extends EventInit {
reason?: string;
wasClean?: boolean;
}
interface ChatUser {
id: string;
username: string;
pfpUrl: string;
displayName?: string;
isBot: boolean;
}

View File

@@ -17,12 +17,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "backtrace"
version = "0.3.75"
@@ -186,12 +180,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -211,12 +199,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-io",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
@@ -501,16 +486,6 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "lock_api"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.27"
@@ -625,29 +600,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "parking_lot"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets 0.52.6",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -705,15 +657,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "redox_syscall"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
dependencies = [
"bitflags 2.9.1",
]
[[package]]
name = "reqwest"
version = "0.11.27"
@@ -803,12 +746,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -882,15 +819,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
dependencies = [
"libc",
]
[[package]]
name = "slab"
version = "0.4.10"
@@ -899,7 +827,7 @@ checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
[[package]]
name = "slack-import-emojis"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"reqwest",
"serde",
@@ -1012,9 +940,7 @@ dependencies = [
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",

View File

@@ -1,10 +1,10 @@
[package]
name = "slack-import-emojis"
version = "0.1.0"
version = "0.2.1"
edition = "2021"
[dependencies]
reqwest = { version = "0.11", features = ["blocking", "json"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

View File

@@ -3,30 +3,53 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::env;
#[derive(Debug, Deserialize)]
struct SlackEmojiResponse {
emoji: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[allow(dead_code)]
error: Option<String>,
emoji: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[allow(dead_code)]
error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DefaultEmojiResponse {
emoji: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct EmojiData {
unified: String,
has_img_twitter: bool,
short_name: String,
}
#[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 emojis = slack_request()
let mut slack_emojis = slack_request()
.await
.expect("Failed to fetch emojis from Slack API");
.expect("Failed to fetch slack_emojis from Slack API");
println!("{:?} slack_emojis fetched", slack_emojis.emoji.len());
if args.len() > 1 && args[1] == "default" {
let default_emojis = default_request()
.await
.expect("Failed to fetch default_emojis from GitHub");
println!("{:?} default_emojis fetched", default_emojis.emoji.len());
slack_emojis.emoji.extend(default_emojis.emoji);
}
println!("{:?} emojis fetched", emojis.emoji.len());
let mut file = File::create("emojis.json").expect("failed to create file for some reason");
let json_data = serde_json::to_string(&emojis.emoji).expect("failed to serialize emojis wtf");
file.write_all(json_data.as_bytes())
let json_data =
serde_json::to_string(&slack_emojis.emoji).expect("failed to serialize emojis wtf");
file
.write_all(json_data.as_bytes())
.expect("failed to write emojis to file");
println!("saved :yay:");
}
@@ -37,7 +60,10 @@ async fn slack_request() -> Result<SlackEmojiResponse, Box<dyn std::error::Error
.get("https://slack.com/api/emoji.list")
.header(
"Authorization",
format!("Bearer {}", std::env::var("SLACK_TOKEN").expect("SLACK_TOKEN not set")),
format!(
"Bearer {}",
std::env::var("SLACK_TOKEN").expect("SLACK_TOKEN not set")
),
)
.send()
.await;
@@ -50,3 +76,29 @@ async fn slack_request() -> Result<SlackEmojiResponse, Box<dyn std::error::Error
}
}
}
async fn default_request() -> Result<DefaultEmojiResponse, Box<dyn std::error::Error>> {
const CDN_URL: &str =
"https://cdn.jsdelivr.net/npm/emoji-datasource-twitter@15.1.2/img/twitter/64/";
let client = reqwest::Client::new();
let res = client
.get("https://raw.githubusercontent.com/iamcal/emoji-data/refs/heads/master/emoji.json")
.send()
.await;
match res {
Ok(response) => {
let emoji_data: Vec<EmojiData> = response.json().await?;
let emoji_map: HashMap<String, String> = emoji_data
.into_iter()
.filter(|e| e.has_img_twitter)
.map(|e| (e.short_name, format!("{}{}.png", CDN_URL, e.unified.to_lowercase())))
.collect();
Ok(DefaultEmojiResponse { emoji: emoji_map })
}
Err(err) => {
eprintln!("Error: {:?}", err);
Err(Box::new(err))
}
}
}

2682
yarn.lock

File diff suppressed because it is too large Load Diff