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: