diff --git a/apps/docs/src/content/docs/api/chat.mdx b/apps/docs/src/content/docs/api/chat.mdx index 0de70bf..a1e1c3f 100644 --- a/apps/docs/src/content/docs/api/chat.mdx +++ b/apps/docs/src/content/docs/api/chat.mdx @@ -9,7 +9,7 @@ The chat system is powered by a websocket server. Please read the entire page be ## Connection and messages -The websocket server is located at `wss://hackclub.tv/api/chat/ws/:username`, where `:username` is the channel you want to connect to. +The websocket server is located at `wss://hackclub.tv/api/stream/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. @@ -21,6 +21,7 @@ Bot accounts are now supported. You can choose to connect as a bot by providing **Security Note:** When using the `?botAuth=` query parameter, be aware that query parameters may be logged in server logs, and/or proxy logs. Use the `Authorization` header method whenever possible. The query parameter method should only be used when connecting from an environment where headers cannot be set. It is highly advised to use a bot account for any automated task, and to implement anything pointed out in this page. + Once connected, you must implement a subroutine in your code to send ping messages every about 5 seconds. This is because of Cloudflare limitations. @@ -79,6 +80,109 @@ Messages are sent and received in JSON format. The following message types are s } ``` +## Moderation messages + +Chat moderation is available over the same websocket connection when the connected account is a channel moderator. + +- `mod:deleteMessage`: delete a message from chat history. + - sent by client: + ```json + { + "type": "mod:deleteMessage", + "msgId": "chat_message_id" + } + ``` + - received by clients in the same channel: + ```json + { + "type": "messageDeleted", + "msgId": "chat_message_id" + } + ``` +- `mod:timeoutUser`: temporarily remove a user's ability to send messages. + - sent by client: + ```json + { + "type": "mod:timeoutUser", + "targetUserId": "user_id", + "targetUsername": "username", + "durationSeconds": 300, + "reason": "optional reason" + } + ``` +- `mod:banUser`: ban a user from chat indefinitely. + - sent by client: + ```json + { + "type": "mod:banUser", + "targetUserId": "user_id", + "targetUsername": "username", + "reason": "optional reason" + } + ``` +- `mod:liftTimeout` and `mod:unbanUser`: remove an existing timeout/ban. + - sent by client: + ```json + { + "type": "mod:unbanUser", + "targetUserId": "user_id", + "targetUsername": "username" + } + ``` +- `chatAccess`: tells a user whether they can currently send messages. + - received by client: + ```json + { + "type": "chatAccess", + "canSend": false, + "restriction": { + "type": "timeout", + "reason": "spamming", + "expiresAt": "2026-02-21T10:00:00.000Z" + } + } + ``` +- `moderationError`: returned when a moderation action is rejected. + - received by client: + ```json + { + "type": "moderationError", + "code": "INVALID_TARGET", + "message": "Invalid moderation target." + } + ``` + +## SDK moderation usage (`@hctv/sdk`) + +If you're building bots, you can call moderation helpers directly: + +```ts +import { HctvSdk } from '@hctv/sdk'; + +const sdk = new HctvSdk({ botToken: process.env.HCTV_BOT_TOKEN! }); +await sdk.chat.connect('channel-name'); + +sdk.chat.timeoutUser('channel-name', 'target-user-id', 'target-username', 300, 'spam'); +sdk.chat.banUser('channel-name', 'target-user-id', 'target-username', 'severe abuse'); +sdk.chat.liftTimeout('channel-name', 'target-user-id', 'target-username'); +sdk.chat.unbanUser('channel-name', 'target-user-id', 'target-username'); +sdk.chat.deleteMessage('channel-name', 'message-id'); + +sdk.chat.onModerationError((error, channel) => { + console.error(`[${channel}] moderation error`, error.code, error.message); +}); + +sdk.chat.onChatAccess((access, channel) => { + console.log(`[${channel}] canSend=${access.canSend}`); +}); + +sdk.chat.onModerationEvent((event) => { + if (event.type === 'messageDeleted') { + console.log('deleted message', event.msgId); + } +}); +``` + ## Emoji handling _diagram source: devin deepwiki_ diff --git a/packages/sdk/examples/moderation-bot.ts b/packages/sdk/examples/moderation-bot.ts new file mode 100644 index 0000000..7ddf168 --- /dev/null +++ b/packages/sdk/examples/moderation-bot.ts @@ -0,0 +1,27 @@ +import { HctvSdk } from '../src/index.js'; + +const botToken = process.env.BOT_TOKEN; +const channelName = process.env.CHANNEL_NAME; + +if (!botToken || !channelName) { + throw new Error('Set BOT_TOKEN and CHANNEL_NAME'); +} + +const sdk = new HctvSdk({ botToken }); +await sdk.chat.connect(channelName); + +sdk.chat.onMessage((message) => { + if (message.isBot || !message.userId) return; + + if (message.message.toLowerCase().includes('badword')) { + sdk.chat.timeoutUser(channelName, message.userId, message.username, 300, 'Used blocked word'); + //sdk.chat.sendMessage(`@${message.username} timed out for 5 minutes.`, channelName); + } +}); + +sdk.chat.onSystemMessage((m) => { + console.log(`[system] ${m.type}: ${m.message}`); +}); +sdk.chat.onModerationError((err) => { + console.log(`[moderation] ${err.code}: ${err.message}`); +}); \ No newline at end of file diff --git a/packages/sdk/src/chat.ts b/packages/sdk/src/chat.ts index 3c941a2..9d2f1b8 100644 --- a/packages/sdk/src/chat.ts +++ b/packages/sdk/src/chat.ts @@ -16,7 +16,7 @@ import type { } from './types'; const DEFAULT_BASE_URL = 'wss://hackclub.tv/api/stream/chat/ws'; -const PING_INTERVAL = 20000; // 20 seconds +const PING_INTERVAL = 5000; // 5 seconds (required to keep CF websocket alive) interface ChannelConnection { ws: WebSocket; @@ -198,6 +198,7 @@ export class ChatClient { return { id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, msgId: msg.msgId, + userId: msg.user.id, channelName, username: msg.user.username, displayName: msg.user.displayName, @@ -272,7 +273,8 @@ export class ChatClient { channelName: string, targetUserId: string, targetUsername: string, - durationSeconds = 300 + durationSeconds = 300, + reason?: string ): void { this.sendModerationCommand( { @@ -280,6 +282,17 @@ export class ChatClient { targetUserId, targetUsername, durationSeconds, + reason, + }, + channelName + ); + } + + deleteMessage(channelName: string, msgId: string): void { + this.sendModerationCommand( + { + type: 'mod:deleteMessage', + msgId, }, channelName ); @@ -313,6 +326,17 @@ export class ChatClient { ); } + unbanUser(channelName: string, targetUserId: string, targetUsername: string): void { + this.sendModerationCommand( + { + type: 'mod:unbanUser', + targetUserId, + targetUsername, + }, + channelName + ); + } + onMessage(handler: MessageHandler, channelName?: string): () => void { if (channelName) { // Channel-specific handler diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 10ca152..b39eeb0 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,6 +1,7 @@ export interface ChatMessage { id: string; msgId?: string; + userId?: string; channelName: string; username: string; displayName?: string; diff --git a/packages/sdk/tests/chat.test.ts b/packages/sdk/tests/chat.test.ts index 721daa9..36b64a7 100644 --- a/packages/sdk/tests/chat.test.ts +++ b/packages/sdk/tests/chat.test.ts @@ -237,6 +237,57 @@ describe('ChatClient', () => { }); }); + describe('moderation commands', () => { + it('should send delete message command', async () => { + await client.connect('testchannel'); + + client.deleteMessage('testchannel', 'msg-123'); + + const mockWs = getMockInstance('testchannel'); + const messages = mockWs!.sentMessages; + const lastMsg = JSON.parse(messages[messages.length - 1]); + + expect(lastMsg).toEqual({ + type: 'mod:deleteMessage', + msgId: 'msg-123', + }); + }); + + it('should send timeout command with reason', async () => { + await client.connect('testchannel'); + + client.timeoutUser('testchannel', 'user-123', 'target-user', 600, 'spam'); + + const mockWs = getMockInstance('testchannel'); + const messages = mockWs!.sentMessages; + const lastMsg = JSON.parse(messages[messages.length - 1]); + + expect(lastMsg).toEqual({ + type: 'mod:timeoutUser', + targetUserId: 'user-123', + targetUsername: 'target-user', + durationSeconds: 600, + reason: 'spam', + }); + }); + + it('should send unban command', async () => { + await client.connect('testchannel'); + + client.unbanUser('testchannel', 'user-123', 'target-user'); + + const mockWs = getMockInstance('testchannel'); + const messages = mockWs!.sentMessages; + const lastMsg = JSON.parse(messages[messages.length - 1]); + + expect(lastMsg).toEqual({ + type: 'mod:unbanUser', + targetUserId: 'user-123', + targetUsername: 'target-user', + }); + }); + }); + describe('onMessage', () => { it('should call global handler when message received', async () => { const messageHandler = vi.fn(); @@ -422,6 +473,74 @@ describe('ChatClient', () => { }); }); + describe('moderation events', () => { + it('should emit moderation error', async () => { + const moderationErrorHandler = vi.fn(); + client.onModerationError(moderationErrorHandler); + + await client.connect('testchannel'); + + getMockInstance('testchannel')?.simulateMessage({ + type: 'moderationError', + code: 'RATE_LIMIT', + message: 'You are sending messages too fast.', + }); + + expect(moderationErrorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'RATE_LIMIT', + message: 'You are sending messages too fast.', + }), + 'testchannel' + ); + }); + + it('should emit chat access state', async () => { + const chatAccessHandler = vi.fn(); + client.onChatAccess(chatAccessHandler); + + await client.connect('testchannel'); + + getMockInstance('testchannel')?.simulateMessage({ + type: 'chatAccess', + canSend: false, + restriction: { + type: 'timeout', + reason: 'Spam', + expiresAt: '2026-01-01T00:00:00.000Z', + }, + }); + + expect(chatAccessHandler).toHaveBeenCalledWith( + expect.objectContaining({ + canSend: false, + restriction: expect.objectContaining({ + type: 'timeout', + }), + }), + 'testchannel' + ); + }); + + it('should emit message deleted moderation event', async () => { + const moderationEventHandler = vi.fn(); + client.onModerationEvent(moderationEventHandler); + + await client.connect('testchannel'); + + getMockInstance('testchannel')?.simulateMessage({ + type: 'messageDeleted', + msgId: 'msg-456', + }); + + expect(moderationEventHandler).toHaveBeenCalledWith({ + type: 'messageDeleted', + msgId: 'msg-456', + channelName: 'testchannel', + }); + }); + }); + describe('onSystemMessage', () => { it('should call handler for system events', async () => { const systemHandler = vi.fn();