From 61972da2551808eaff0c14138c47de873f2ddd74 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 15:54:24 +0000
Subject: [PATCH 1/6] Initial plan
From 70832c7de872585e0cda7d8078124fc62cc8a1c5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 15:58:51 +0000
Subject: [PATCH 2/6] Add botAuth query parameter support for websocket
authentication
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
---
apps/chat/src/index.ts | 58 +++++++++++++++----------
apps/docs/src/content/docs/api/chat.mdx | 33 +++++++++-----
2 files changed, 55 insertions(+), 36 deletions(-)
diff --git a/apps/chat/src/index.ts b/apps/chat/src/index.ts
index 03d92bf..f808475 100644
--- a/apps/chat/src/index.ts
+++ b/apps/chat/src/index.ts
@@ -33,8 +33,9 @@ app.get(
const token = getCookie(c, 'auth_session');
const grant = c.req.query('grant');
const authHeader = c.req.header('Authorization');
+ const botAuth = c.req.query('botAuth');
- if (!token && (!grant || grant === 'null') && !authHeader) {
+ if (!token && (!grant || grant === 'null') && !authHeader && !botAuth) {
ws.close();
return;
}
@@ -42,11 +43,18 @@ app.get(
let chatUser: ChatUser | null = null;
let personalChannel: any = null;
+ // Check for bot authentication via Authorization header or botAuth query parameter
+ let apiKey: string | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
- const apiKey = authHeader.substring(7);
+ apiKey = authHeader.substring(7);
+ } else if (botAuth) {
+ apiKey = botAuth;
+ }
+
+ if (apiKey) {
const botAccount = await prisma.botApiKey.findUnique({
where: { key: apiKey },
- include: { botAccount: true }
+ include: { botAccount: true },
});
if (botAccount) {
@@ -55,12 +63,12 @@ app.get(
username: botAccount.botAccount.slug,
pfpUrl: botAccount.botAccount.pfpUrl,
displayName: botAccount.botAccount.displayName,
- isBot: true
+ isBot: true,
};
personalChannel = {
id: botAccount.botAccount.id,
- name: botAccount.botAccount.slug
+ name: botAccount.botAccount.slug,
};
}
}
@@ -74,7 +82,7 @@ app.get(
id: session.user.id,
username: userChannel.name,
pfpUrl: session.user.pfpUrl,
- isBot: false
+ isBot: false,
};
personalChannel = userChannel;
}
@@ -82,7 +90,7 @@ app.get(
}
const dbGrant = await prisma.channel.findFirst({
- where: { obsChatGrantToken: grant }
+ where: { obsChatGrantToken: grant },
});
if (!chatUser && !dbGrant) {
@@ -100,7 +108,7 @@ app.get(
ws.chatUser = chatUser;
ws.personalChannel = personalChannel;
ws.viewerId = randomString(10);
-
+
if (ws.raw) {
ws.raw.targetUsername = username;
ws.raw.chatUser = chatUser;
@@ -111,10 +119,12 @@ app.get(
const messages = await redis.zrange(channelKey, 0, MESSAGE_HISTORY_SIZE - 1);
if (messages.length > 0) {
- ws.send(JSON.stringify({
- type: 'history',
- messages: messages.map((msg) => JSON.parse(msg)),
- }));
+ ws.send(
+ JSON.stringify({
+ type: 'history',
+ messages: messages.map((msg) => JSON.parse(msg)),
+ })
+ );
}
},
async onClose(evt, ws) {
@@ -137,7 +147,7 @@ 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' }));
@@ -146,7 +156,7 @@ app.get(
if (msg.type === 'message') {
if (!ws.chatUser || !ws.personalChannel) return;
-
+
const message = (msg.message as string).trim();
const msgObj = {
user: {
@@ -154,17 +164,17 @@ app.get(
username: ws.chatUser.username,
pfpUrl: ws.chatUser.pfpUrl,
displayName: ws.chatUser.displayName,
- isBot: ws.chatUser.isBot || false
+ isBot: ws.chatUser.isBot || false,
},
message,
};
-
+
const redisObj = {
- user: msgObj.user,
+ user: msgObj.user,
message: msgObj.message,
type: 'message',
};
-
+
const redisStr = JSON.stringify(redisObj);
const msgStr = JSON.stringify(msgObj);
@@ -187,14 +197,14 @@ app.get(
await Promise.all(
emojis.map(async (emoji) => {
let url = await redis.hget('emojis', emoji);
-
+
if (!url) {
url = await redis.hget(`emojis:${emoji}`, 'url');
}
if (!url) {
url = await redis.hget(`emoji:${emoji}`, 'url');
}
-
+
emojiMap[emoji] = url ?? '';
})
);
@@ -214,10 +224,10 @@ app.get(
const emojiKeys = Object.keys(emojis);
const idxs = uf.filter(emojiKeys, searchTerm);
console.log(`Emoji search for "${searchTerm}" found ${idxs?.length || 0} results.`);
-
+
if (idxs && idxs.length > 0) {
const results: string[] = [];
-
+
if (idxs.length <= 150) {
const info = uf.info(idxs, emojiKeys, searchTerm);
const order = uf.sort(info, emojiKeys, searchTerm);
@@ -229,7 +239,7 @@ app.get(
results.push(emojiKeys[idxs[i]]);
}
}
-
+
ws.send(
JSON.stringify({
type: 'emojiSearchResponse',
@@ -267,4 +277,4 @@ interface ChatUser {
pfpUrl: string;
displayName?: string;
isBot: boolean;
-}
\ No newline at end of file
+}
diff --git a/apps/docs/src/content/docs/api/chat.mdx b/apps/docs/src/content/docs/api/chat.mdx
index 96565e6..4325e66 100644
--- a/apps/docs/src/content/docs/api/chat.mdx
+++ b/apps/docs/src/content/docs/api/chat.mdx
@@ -11,24 +11,30 @@ The chat system is powered by a websocket server. Please read the entire page be
The websocket server is located at `wss://hackclub.tv/api/chat/ws/:username`, where `:username` is the channel you want to connect to.
-You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
+You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
+
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:
+
+- `message`: a chat message.
+ - sent by client:
```json
{
"type": "message",
"content": "Hello, world!"
}
```
- - received by client:
+ - received by client:
```json
{
"user": {
@@ -36,24 +42,24 @@ Messages are sent and received in JSON format. The following message types are s
"username": "user_who_sent_message",
"avatar": "https://emoji.slack-edge.com/avatar.png"
},
- "message": "Hello, world!",
+ "message": "Hello, world!"
}
```
-- `ping`: a ping message to keep the connection alive.
- - sent by client:
+- `ping`: a ping message to keep the connection alive.
+ - sent by client:
```json
{
"type": "ping"
}
```
- - received by client:
+ - received by client:
```json
{
"type": "ping"
}
```
-- `history`: a message containing the chat history. This is sent upon connection.
- - received by client:
+- `history`: a message containing the chat history. This is sent upon connection.
+ - received by client:
```json
{
"type": "history",
@@ -71,9 +77,11 @@ Messages are sent and received in JSON format. The following message types are s
]
}
```
+
## Emoji handling
-*diagram source: devin deepwiki*
+_diagram source: devin deepwiki_
+
```mermaid
graph TB
subgraph "Emoji Processing Pipeline"
@@ -111,6 +119,7 @@ The server then checks Redis for the emoji URL and returns it.
When a user wants to look up an emoji (by typing `:(partial name)`), the server uses uFuzzy to find matching emojis in the Redis `emojis` hash key and returns the results.
Here's what gets sent on the websocket:
+
- `emojiMsg`: Looks up emojis
- sent by client:
```json
From 7262b0e5c283a0bb13cc033d046f02dbb85eabd5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 16:01:34 +0000
Subject: [PATCH 3/6] Add security validation and documentation for botAuth
parameter
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
---
apps/chat/src/index.ts | 7 +++++--
apps/docs/src/content/docs/api/chat.mdx | 4 +++-
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/apps/chat/src/index.ts b/apps/chat/src/index.ts
index f808475..cd1f976 100644
--- a/apps/chat/src/index.ts
+++ b/apps/chat/src/index.ts
@@ -47,8 +47,11 @@ app.get(
let apiKey: string | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
apiKey = authHeader.substring(7);
- } else if (botAuth) {
- apiKey = botAuth;
+ } else if (botAuth && typeof botAuth === 'string' && botAuth.trim().length > 0) {
+ // Validate botAuth query parameter format
+ if (botAuth.startsWith('hctvb_')) {
+ apiKey = botAuth;
+ }
}
if (apiKey) {
diff --git a/apps/docs/src/content/docs/api/chat.mdx b/apps/docs/src/content/docs/api/chat.mdx
index 4325e66..bacbd72 100644
--- a/apps/docs/src/content/docs/api/chat.mdx
+++ b/apps/docs/src/content/docs/api/chat.mdx
@@ -15,9 +15,11 @@ You'll need to provide authentication, which can be done by providing an `auth_s
From 1fadaa3600c5283c28a7cdbe7c05798dad380488 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 30 Jan 2026 16:04:33 +0000
Subject: [PATCH 4/6] Add consistent prefix validation for both auth methods
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
---
apps/chat/src/index.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/apps/chat/src/index.ts b/apps/chat/src/index.ts
index cd1f976..0e2fcb0 100644
--- a/apps/chat/src/index.ts
+++ b/apps/chat/src/index.ts
@@ -44,9 +44,14 @@ app.get(
let personalChannel: any = null;
// Check for bot authentication via Authorization header or botAuth query parameter
+ // Authorization header takes precedence if both are provided
let apiKey: string | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
- apiKey = authHeader.substring(7);
+ const extractedKey = authHeader.substring(7);
+ // Validate the API key format before attempting database lookup
+ if (extractedKey.startsWith('hctvb_')) {
+ apiKey = extractedKey;
+ }
} else if (botAuth && typeof botAuth === 'string' && botAuth.trim().length > 0) {
// Validate botAuth query parameter format
if (botAuth.startsWith('hctvb_')) {
From 75d6e648f94bb31953d25bfc2e698075191bcc8e Mon Sep 17 00:00:00 2001
From: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
Date: Fri, 30 Jan 2026 17:10:06 +0100
Subject: [PATCH 5/6] chore: remove comments
---
apps/chat/src/index.ts | 4 ----
1 file changed, 4 deletions(-)
diff --git a/apps/chat/src/index.ts b/apps/chat/src/index.ts
index 0e2fcb0..04e0c7e 100644
--- a/apps/chat/src/index.ts
+++ b/apps/chat/src/index.ts
@@ -43,17 +43,13 @@ app.get(
let chatUser: ChatUser | null = null;
let personalChannel: any = null;
- // Check for bot authentication via Authorization header or botAuth query parameter
- // Authorization header takes precedence if both are provided
let apiKey: string | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
const extractedKey = authHeader.substring(7);
- // Validate the API key format before attempting database lookup
if (extractedKey.startsWith('hctvb_')) {
apiKey = extractedKey;
}
} else if (botAuth && typeof botAuth === 'string' && botAuth.trim().length > 0) {
- // Validate botAuth query parameter format
if (botAuth.startsWith('hctvb_')) {
apiKey = botAuth;
}
From 3e5824093ef00065660d5002c0b4783096996844 Mon Sep 17 00:00:00 2001
From: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
Date: Fri, 30 Jan 2026 17:12:25 +0100
Subject: [PATCH 6/6] docs: change some phrasing
---
apps/docs/src/content/docs/api/chat.mdx | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/apps/docs/src/content/docs/api/chat.mdx b/apps/docs/src/content/docs/api/chat.mdx
index bacbd72..b12a3d1 100644
--- a/apps/docs/src/content/docs/api/chat.mdx
+++ b/apps/docs/src/content/docs/api/chat.mdx
@@ -15,16 +15,15 @@ You'll need to provide authentication, which can be done by providing an `auth_s
-Once connected, you must implement a subroutine in your code to send ping messages every 5 seconds. This is because of Cloudflare limitations.
+Once connected, you must implement a subroutine in your code to send ping messages every about 5 seconds. This is because of Cloudflare limitations.
Messages are sent and received in JSON format. The following message types are supported: