Compare commits

...

1 Commits

Author SHA1 Message Date
7548390c1d feat(sdk): moderation features 2026-03-02 16:16:10 +01:00
5 changed files with 278 additions and 3 deletions

View File

@@ -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.
</Aside>
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_

View File

@@ -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}`);
});

View File

@@ -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

View File

@@ -1,6 +1,7 @@
export interface ChatMessage {
id: string;
msgId?: string;
userId?: string;
channelName: string;
username: string;
displayName?: string;

View File

@@ -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();