mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
Compare commits
1 Commits
1c3aaec48d
...
feat/sdk-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 7548390c1d |
@@ -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_
|
||||
|
||||
27
packages/sdk/examples/moderation-bot.ts
Normal file
27
packages/sdk/examples/moderation-bot.ts
Normal 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}`);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
msgId?: string;
|
||||
userId?: string;
|
||||
channelName: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user