Compare commits

...

54 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
6710423b92 feat: complete api docs 2025-09-02 00:03:53 +02:00
a4f940b990 feat: docs part 1 2025-09-01 01:57:37 +02:00
307b697ac9 chore: remove all unused livekit packages 2025-09-01 01:51:31 +02:00
49c0da708a chore: bye bye openapi 2025-09-01 01:22:39 +02:00
4c415dacc4 feat: scalar openapi stuff 2025-09-01 01:06:36 +02:00
e616ac20d4 feat: initial docs 2025-08-31 19:05:39 +02:00
387f0943a3 feat: new onboarding 2025-08-31 18:14:54 +02:00
a22937793c fix: builds 2025-08-31 17:44:55 +02:00
302e9be737 chore: remove placeholder link thing 2025-08-26 19:21:53 +02:00
d5d8894f9c fix: auth not working on hls route 2025-08-24 22:36:29 +02:00
c10b4b3674 feat: sentry 2025-08-24 01:00:44 +02:00
c18cdc83b9 fix: redirects not working correctly on programs like ffmpeg 2025-08-23 20:13:19 +02:00
6e10da6f98 fix: badly performing code 2025-08-23 19:44:01 +02:00
6a7b449363 fix: postgres direct connections for migrations 2025-08-23 17:34:24 +02:00
ab72dacb61 fix: more webplayer improvements 2025-08-21 21:17:25 +02:00
31d27f92ca fix: webplayer stuff to eliminate constant buffering 2025-08-21 20:50:50 +02:00
cd685edd78 chore: debug 2025-08-21 19:10:15 +02:00
bd7047d19b fix: dockerfile emojis 2025-08-20 19:14:12 +02:00
9a7c72321d fix: other fixes aa 2025-08-20 18:56:50 +02:00
8d6e097f94 fix: more redis stuff 2025-08-20 18:34:54 +02:00
3fbb2b5d7f fix: ioredis errors 2025-08-20 18:31:40 +02:00
834e5087c3 fix: ci lemao 2025-08-20 18:23:30 +02:00
2e2185568d ci: fix again aaaaa 2025-08-17 21:17:57 +02:00
85 changed files with 7753 additions and 674 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
@@ -31,19 +37,19 @@ jobs:
- name: Download latest emoji importer
run: |
RELEASE_URL=$(curl -s https://api.github.com/repos/srizan10/hclive/releases/latest | \
RELEASE_URL=$(curl -s https://api.github.com/repos/srizan10/hctv/releases/latest | \
grep "browser_download_url.*slack-import-emojis-linux-x86_64" | \
cut -d '"' -f 4)
curl -L -o slack-import-emojis $RELEASE_URL
chmod +x slack-import-emojis
curl -L -o slack-import-emojis-bin $RELEASE_URL
chmod +x slack-import-emojis-bin
mkdir -p apps/web/src/lib/instrumentation/
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
./slack-import-emojis
mv emojis.json apps/web/src/lib/instrumentation/
./slack-import-emojis-bin default
cp emojis.json apps/web/
- name: Build and push Docker image
uses: docker/build-push-action@v6

View File

@@ -52,9 +52,6 @@ jobs:
export SLACK_TOKEN=xoxb-your-token
./slack-import-emojis
```
draft: false
prerelease: false
files: ./slack-import-emojis/target/release/slack-import-emojis
- name: Upload Linux binary
uses: actions/upload-release-asset@v1

4
.gitignore vendored
View File

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

8
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"servers": {
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/sr-izan/hctv",
"type": "http"
}
}
}

View File

@@ -5,9 +5,3 @@ This is the source code for [hackclub.tv (hctv.srizan.dev)](https://hctv.srizan.
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.
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
## Features
- High quality video streaming (low latency coming soon)
- Chat with other viewers
- Multiaccount support (database schema laid out, UI not implemented)

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;
}

21
apps/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 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

@@ -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" }
}),
]
}),
],
});

20
apps/docs/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@hctv/docs",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/starlight": "^0.35.2",
"@catppuccin/starlight": "^1.0.2",
"astro": "^5.6.1",
"astro-mermaid": "^1.0.4",
"mermaid": "^11.10.1",
"sharp": "^0.34.2"
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

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

@@ -0,0 +1,162 @@
---
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.
<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.
Messages are sent and received in JSON format. The following message types are supported:
- `message`: a chat message.
- sent by client:
```json
{
"type": "message",
"content": "Hello, world!"
}
```
- received by client:
```json
{
"user": {
"id": "user_id",
"username": "user_who_sent_message",
"avatar": "https://emoji.slack-edge.com/avatar.png"
},
"message": "Hello, world!",
}
```
- `ping`: a ping message to keep the connection alive.
- sent by client:
```json
{
"type": "ping"
}
```
- received by client:
```json
{
"type": "ping"
}
```
- `history`: a message containing the chat history. This is sent upon connection.
- received by client:
```json
{
"type": "history",
"messages": [
{
"user": {
"id": "user_id",
"username": "user_who_sent_message",
"avatar": "https://emoji.slack-edge.com/avatar.png"
},
"message": "Hello, world!",
"type": "message",
},
...
]
}
```
## Emoji handling
*diagram source: devin deepwiki*
```mermaid
graph TB
subgraph "Emoji Processing Pipeline"
CHAT_MSG["Chat Message"]
PATTERN_MATCH["Regex :emoji: Pattern"]
EMOJI_REQUEST["emojiMsg WebSocket"]
REDIS_LOOKUP["Redis HGET emojis"]
FUZZY_SEARCH["uFuzzy"]
EMOJI_RESPONSE["emojiMsgResponse"]
end
subgraph "Redis Storage"
EMOJI_HASH["emojis hash key"]
EMOJI_PREFIXED["emoji:{name} url"]
EMOJIS_PREFIXED["emojis:{name} url"]
end
CHAT_MSG --> PATTERN_MATCH
PATTERN_MATCH --":emojiname:"--> EMOJI_REQUEST
EMOJI_REQUEST --> REDIS_LOOKUP
REDIS_LOOKUP --> EMOJI_HASH
REDIS_LOOKUP --> EMOJI_PREFIXED
REDIS_LOOKUP --> EMOJIS_PREFIXED
REDIS_LOOKUP --> EMOJI_RESPONSE
FUZZY_SEARCH --> EMOJI_HASH
FUZZY_SEARCH --"search results"--> EMOJI_RESPONSE
```
When a chat message is sent, the server looks for patterns in the format `:emojiname:` using regex. For each match, it sends a request to the `emojiMsg` WebSocket.
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
{
"type": "emojiMsg",
"emojis": ["aga", "yapa", "heavysob", "yay", "yay-bounce"]
}
```
- received by client:
```json
{
"type": "emojiMsgResponse",
"emojis": {
// rough example of urls
"aga": "https://emoji.slack-edge.com/aga.png",
"yapa": "https://emoji.slack-edge.com/yapa.png",
"heavysob": "https://emoji.slack-edge.com/heavysob.png",
"yay": "https://emoji.slack-edge.com/yay.png",
"yay-bounce": "https://emoji.slack-edge.com/yay-bounce.png"
}
}
```
- `emojiSearch`: Searches for emojis
- sent by client:
```json
{
"type": "emojiSearch",
"searchTerm": "aga"
}
```
- received by client:
```json
{
"type": "emojiSearchResponse",
"results": [
// real results btw
"aga",
"aga-brick-throw",
"aga-dance",
"aga-transparent",
"a-aga",
"a-aga-transparent",
"agaban",
"agaboing",
"agabounce",
"agabusiness"
]
}
```

View File

@@ -0,0 +1,19 @@
---
title: API Documentation
description: Documented API endpoints for hackclub.tv
---
hctv is meant to be one of the most hackable streaming platforms out there. to that end, we have a (currently limited) public API that you can use to interact with the platform.
since this is beta software, the API is subject to change. additionally, many endpoints are yet not implemented or not fun to implement. please send a message in #hctv on the Hack Club Slack if you have any requests.
## Base url
base url for all endpoints is `https://hctv.srizan.dev/api`.
## Authentication
most endpoints require authentication. this will be pointed out in the documentation.
for now, it is done via a cookie called `auth_session` (as per lucia auth). this will change in the future as bot accounts are planned.
you'll need your user account to authenticate. as a recommendation, open an incognito window, log in to hctv, and copy the `auth_session` cookie from there.

View File

@@ -0,0 +1,4 @@
{
"title": "API",
"description": "Documented API endpoints for hackclub.tv"
}

View File

@@ -0,0 +1,16 @@
---
title: RTMP
description: RTMP related endpoint group
---
## GET `/rtmp/hls/:path`
gets HLS segments, the backbone of hctv livestreaming. **authentication required**.
not really sure why you would need this? but check the browser console when playing a stream for an insight on how it's used.
## POST `/rtmp/streamKey`
regenerates your stream key. **authentication required**.
body parameters (json):
- `channel`: string - the channel name you want to regenerate the key for. must be one of your channels.
response (json):
- `key`: string - the new stream key

View File

@@ -0,0 +1,47 @@
---
title: Stream
description: Stream related endpoint group
---
## GET `/stream/follow`
checks if **authenticated user** is following a certain channel.
query parameters:
- `username`: string - the channel name you want to check.
response (json):
- `following`: boolean - whether the authenticated user is following the channel or not.
## POST `/stream/follow`
cycle through follow or unfollow a channel. **authentication required**.
body parameters (json):
- `channel`: string - the channel name you want to make the action. must be one of your channels.
response (json):
- `success`: boolean - whether the operation was successful or not.
## GET `/stream/followers/:channel`
gets the followcount of a channel.
path parameters:
- `channel`: string - the channel name you want to get the followcount of.
response (json):
- `count`: integer - the number of followers the channel has.
- `success`: boolean - whether the operation was successful or not. (true if 200 status code)
## GET `/stream/info`
get stream info of certain channels by filtering. **authentication on some**.
query parameters:
- `owned`: boolean (optional) - if true, only returns channels owned by the authenticated user. requires authentication.
- `personal`: boolean (optional) - if true, only returns personal channels. requires authentication.
- `live`: boolean (optional) - if true, only returns channels that are currently live.
response (json):
- StreamInfo[] (check database schema for all returned data or just try it out!)
## GET `/stream/thumb/:channel`
gets the preview thumbnail of a channel's livestream. **authentication required**
path parameters:
- `channel`: string - the channel name you want to get the thumbnail of.
response: image (webp)

View File

@@ -0,0 +1,4 @@
{
"title": "Guides",
"description": "Useful guides to help you get started with hackclub.tv"
}

View File

@@ -0,0 +1,15 @@
---
title: How to stream
description: Get started with OBS and streaming on hackclub.tv
---
- open obs
- open settings
- open "Stream"
- set service to custom
- set url to `rtmp://hackclub.app:45913/live`
- on the website, click "Regenerate key"
- paste the key into your obs "Stream Key"
- start streaming!
(screenshots to be added soon)

View File

@@ -0,0 +1,8 @@
---
title: About hctv docs
description: Index documentation page!
---
Welcome to the hackclub.tv docs! Here you'll find tips and tricks to get the most out of hackclub.tv and its features.
Additionally, some useful guides are available to help you get started with common tasks.

5
apps/docs/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"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
@@ -63,5 +65,6 @@ USER nextjs
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --chown=nextjs:nodejs apps/web/emojis.json .
CMD ["/usr/local/bin/start.sh"]

View File

@@ -1,3 +1,4 @@
import {withSentryConfig} from '@sentry/nextjs';
import * as path from 'node:path';
import { fileURLToPath } from 'url';
import { readFileSync } from 'node:fs';
@@ -8,7 +9,7 @@ const __dirname = path.dirname(__filename);
const LIVE_SERVER_URL =
process.env.NODE_ENV === 'production'
? 'https://backend.hctv.srizan.dev'
? 'http://nginx-rtmp:8888'
: 'http://localhost:8888';
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
@@ -27,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,
},
@@ -39,6 +47,7 @@ const nextConfig = {
reactStrictMode: false,
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '../../'),
serverExternalPackages: ['bullmq'],
async rewrites() {
return [
{
@@ -49,4 +58,35 @@ const nextConfig = {
},
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: "sr-izan",
project: "hctv",
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: 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-
// side errors will fail.
tunnelRoute: "/monitoring",
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
});

View File

@@ -11,15 +11,16 @@
"start": "next start",
"lint": "next lint",
"ui:add": "shadcn add",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"openapi": "next-openapi-gen"
},
"dependencies": {
"@hctv/auth": "*",
"@hctv/db": "*",
"@hookform/resolvers": "^3.9.1",
"@livekit/components-react": "^2.7.0",
"@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",
@@ -33,9 +34,12 @@
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@scalar/api-reference-react": "^0.7.42",
"@sentry/nextjs": "^10",
"@slack/web-api": "^7.9.1",
"@uidotdev/usehooks": "^2.4.1",
"@uploadthing/react": "^7.3.1",
"ajv": "^8.17.1",
"arctic": "^3.7.0",
"bullmq": "^5.45.2",
"cheerio": "^1.0.0",
@@ -43,13 +47,10 @@
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"hls-video-element": "^1.5.0",
"ioredis": "^5.6.0",
"livekit-client": "^2.8.0",
"livekit-server-sdk": "^2.9.7",
"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",
@@ -63,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

@@ -0,0 +1,20 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// 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,
enabled: process.env.NODE_ENV === 'production',
});

View File

@@ -6,9 +6,16 @@ import { cookies } from 'next/headers';
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
const { path } = await params;
const c = await cookies();
if (!getRedisConnection().exists(`sessions:${c.get('auth_session')?.value}`)) {
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 });
}

View File

@@ -3,7 +3,12 @@ import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const formData = await request.formData();
const streamKey = formData.get('name')?.toString() || '';
const streamKey = formData.get('name');
if (typeof streamKey !== 'string') {
return new Response('bad request', {
status: 400,
});
}
const key = await prisma.streamKey.findFirst({
where: {
@@ -19,12 +24,10 @@ export async function POST(request: NextRequest) {
status: 403,
});
}
const headers = new Headers();
headers.append('Location', `rtmp://127.0.0.1/channel-live/${key.channel.name}`);
return new Response(null, {
return new Response('', {
status: 302,
headers: headers,
headers: {
'Location': key.channel.name,
},
});
}

View File

@@ -11,6 +11,10 @@ export async function POST(request: NextRequest) {
return new Response('Unauthorized', { status: 401 });
}
if (!channel || typeof channel !== 'string') {
return new Response('Bad Request', { status: 400 });
}
const channelInfo = await prisma.channel.findUnique({
where: { name: channel },
include: {

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

@@ -4,32 +4,127 @@ import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { onboard } from '@/lib/form/actions';
import { useSession } from '@/lib/providers/SessionProvider';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, Tv, Heart, MessageSquare } from 'lucide-react';
import Image from 'next/image';
export default function OnboardingClient() {
const { user } = useSession();
return (
<Card className="mx-auto max-w-sm border-0 shadow-none">
<CardHeader className="space-y-1">
<CardTitle>Welcome to hackclub.tv!</CardTitle>
<CardDescription>
To get started, please enter the username of your personal channel.
</CardDescription>
</CardHeader>
<CardContent>
<p>join #hctv! you will get welcomed to the channel after submitting the form!</p>
<UniversalForm
fields={[
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
{ name: 'username', label: 'Username', type: 'text' },
]}
schemaName="onboard"
action={onboard}
onActionComplete={() => {
window.location.href = '/';
}}
/>
</CardContent>
</Card>
<div className="min-h-[93vh] flex items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-8">
{/* welcome header */}
<div className="text-center space-y-2">
<div className="flex justify-center">
<div className="relative">
<Avatar className="h-20 w-20 border-4 border-primary/20">
<AvatarImage src={user?.pfpUrl} alt={`@${user?.id}`} />
<AvatarFallback className="text-2xl font-bold">
{user?.id?.charAt(0)?.toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">
Welcome to hackclub.tv!
</h1>
<p className="text-lg text-muted-foreground flex gap-2 justify-center">
Let&apos;s get you set up <Image src="https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png" alt=":blahaj-heart:" width={24} height={24} />
</p>
</div>
</div>
{/* explanation */}
<Card className="border-2 border-primary/10 bg-primary/5">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-xl">
<User className="h-5 w-5 text-primary" />
Why do you need a personal channel?
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
<Tv className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-semibold text-sm">Stream content</h3>
<p className="text-xs text-muted-foreground">
Share your coding sessions and projects!
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
<MessageSquare className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-semibold text-sm">Chat with others</h3>
<p className="text-xs text-muted-foreground">
Connect with other Hack Clubbers and grow your audience
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
<Heart className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-semibold text-sm">Follow hackclubbers</h3>
<p className="text-xs text-muted-foreground">
Stay updated with your favorite creators and streams
</p>
</div>
</div>
</div>
<div className="mt-6 p-4 bg-secondary/50 rounded-lg border border-muted">
<p className="text-sm text-muted-foreground">
<strong>Your personal channel</strong> is your home base on hctv.
It&apos;s where your profile, streams, and content will live. You can always create
additional channels later for different types of content!
</p>
</div>
</CardContent>
</Card>
{/* form */}
<Card className="shadow-lg">
<CardHeader className="text-center">
<CardTitle className="text-xl">Choose Your Channel Username</CardTitle>
<CardDescription>
This will be your unique identifier on hctv. Choose something memorable!
</CardDescription>
</CardHeader>
<CardContent>
<UniversalForm
fields={[
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
{
name: 'username',
label: 'Channel Username',
type: 'text',
placeholder: 'e.g., yourname or yourname-codes'
},
]}
schemaName="onboard"
action={onboard}
onActionComplete={() => {
window.location.href = '/';
}}
/>
<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.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
}

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

@@ -0,0 +1,23 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

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

@@ -1,35 +0,0 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import Link from "next/link";
import { links } from "../NavBar/NavBar";
export default function MobileNavbarLinks() {
return (
<div className="flex md:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>Menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>stack</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{links.map((link) => (
<Link key={link.href} href={link.href}>
<DropdownMenuItem>{link.name}</DropdownMenuItem>
</Link>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -18,20 +18,6 @@ import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
import { Slack } from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
export const links = [{ href: '/', name: 'home (placeholder link)' }];
function NavbarLinks() {
return (
<>
{links.map((link) => (
<Link key={link.href} href={link.href}>
<Button variant={'link'}>{link.name}</Button>
</Link>
))}
</>
);
}
export default function Navbar(props: Props) {
const { user } = useSession();
return (
@@ -46,10 +32,6 @@ export default function Navbar(props: Props) {
<SidebarTrigger />
</div>
<div className="hidden md:flex">
<NavbarLinks />
</div>
{/* Right Side Items */}
<div className="flex items-center gap-1 md:gap-3 shrink-0">
{props.editLivestream && <div className="hidden sm:block">{props.editLivestream}</div>}
@@ -69,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,19 +26,35 @@ export default function StreamPlayer() {
autoplay
config={{
lowLatencyMode: true,
liveSyncDurationCount: 2, // Use only 1 segment for sync
liveMaxLatencyDurationCount: 3, // Maximum latency allowed
liveSyncDurationCount: 1,
liveMaxLatencyDurationCount: 2,
liveDurationInfinity: true,
enableWorker: true,
backBufferLength: 0, // No back buffer
startLevel: -1, // Auto level selection
maxBufferLength: 4, // Maximum buffer length in seconds
maxMaxBufferLength: 6,
debug: false,
backBufferLength: 1,
startLevel: -1,
maxBufferLength: 2,
maxMaxBufferLength: 4,
startFragPrefetch: true,
testBandwidth: false,
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

@@ -0,0 +1,20 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// 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,
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

@@ -24,12 +24,15 @@ export async function emojisWriteRedis() {
function getPath() {
const possiblePaths = [
// original
// docker shit
'/app/emojis.json',
'/app/apps/web/emojis.json',
// cwd shit
path.join(process.cwd(), 'emojis.json'),
path.join(process.cwd(), 'apps/web/emojis.json'),
// fallbacks
'./emojis.json',
'src/lib/instrumentation/emojis.json',
// relative
path.join(__dirname, 'emojis.json'),
// standalone nextjs
path.join(process.cwd(), 'src/lib/instrumentation/emojis.json')
];
console.log('Writing emojis to Redis...');

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

@@ -2,21 +2,39 @@ import { getRedisConnection, prisma } from "@hctv/db";
export async function viewerCountSync() {
const streams = await prisma.streamInfo.findMany({
where: {
isLive: true
},
include: {
channel: true
}
})
const redis = getRedisConnection();
for (const stream of streams) {
const viewerCount = await redis.keys(`viewer:${stream.channel.name}:*`);
await prisma.streamInfo.update({
where: {
username: stream.username,
},
data: {
viewers: viewerCount.length,
},
});
if (streams.length === 0) {
return;
}
const redis = getRedisConnection();
const multi = redis.multi();
for (const stream of streams) {
multi.keys(`viewer:${stream.channel.name}:*`);
}
const results = await multi.exec();
await prisma.$transaction(async (tx) => {
const updates = results?.map((res, index) => {
const count = Array.isArray(res[1]) ? res[1].length : 0;
const stream = streams[index];
return tx.streamInfo.update({
where: {
// using username here because it uses a map
username: stream.username
},
data: {
viewers: count
}
})
})
await Promise.all(updates || []);
})
}

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

@@ -17,7 +17,7 @@ if (!globalForNotifier.notificationQueue) {
export function getNotificationQueue(): Queue {
if (!globalForNotifier.notificationQueue) {
globalForNotifier.notificationQueue = new Queue('notifications', {
connection: getRedisConnection(),
connection: getRedisConnection().options,
defaultJobOptions: {
attempts: 3,
backoff: {
@@ -33,7 +33,7 @@ export function getNotificationQueue(): Queue {
export function getThumbnailQueue(): Queue {
if (!globalForNotifier.thumbnailQueue) {
globalForNotifier.thumbnailQueue = new Queue('thumbnails', {
connection: getRedisConnection(),
connection: getRedisConnection().options,
defaultJobOptions: {
attempts: 3,
backoff: {

View File

@@ -28,7 +28,7 @@ export async function registerNotificationWorker(): Promise<void> {
return { success: false, error: e.message };
}
}, {
connection: getRedisConnection(),
connection: getRedisConnection().options,
concurrency: 1,
limiter: {
max: 45,

View File

@@ -55,7 +55,7 @@ export async function registerThumbnailWorker(): Promise<void> {
}
},
{
connection: getRedisConnection(),
connection: getRedisConnection().options,
concurrency: 3,
limiter: {
max: 50,

View File

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

View File

@@ -10,7 +10,10 @@ rtmp {
live on;
record off;
push rtmp://localhost:1935/channel-live;
on_publish http://localhost:3000/api/rtmp/publish;
deny play all;
}
application channel-live {
@@ -19,6 +22,8 @@ rtmp {
allow publish 127.0.0.1;
deny publish all;
deny play all;
hls on;
hls_type live;
@@ -27,9 +32,12 @@ rtmp {
hls_playlist_length 10s;
hls_cleanup on;
hls_variant _low BANDWIDTH=500000;
hls_variant _mid BANDWIDTH=1000000;
hls_variant _hi BANDWIDTH=1500000;
hls_fragment_naming timestamp;
hls_fragment_slicing aligned;
hls_variant _low BANDWIDTH=300000 RESOLUTION=480x270;
hls_variant _mid BANDWIDTH=600000 RESOLUTION=640x360;
hls_variant _hi BANDWIDTH=1000000 RESOLUTION=854x480;
}
}
}

View File

@@ -15,9 +15,12 @@
"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"
"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"
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.4.4"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"

View File

@@ -11,7 +11,7 @@
"type": "module",
"dependencies": {
"@prisma/client": "^6.5.0",
"ioredis": "^5.6.1",
"ioredis": "5.7.0",
"prisma": "^6.5.0"
},
"scripts": {

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "StreamInfo_username_idx" ON "StreamInfo"("username");

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,53 +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])
}
@@ -63,36 +66,36 @@ 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)
// TODO: index on username
@@index([username])
}
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])
@@ -101,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))
}
}
}

View File

@@ -4,7 +4,7 @@
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**", "generated/client/**"],
"env": ["commit", "NODE_ENV", "TURBO_TOKEN", "TURBO_TEAM"]
"env": ["commit", "NODE_ENV", "TURBO_TOKEN", "TURBO_TEAM", "SENTRY_AUTH_TOKEN"]
},
"setup": {
"dependsOn": ["^dd", "^db:generate", "^build"],

6036
yarn.lock

File diff suppressed because it is too large Load Diff