feat: support connecting to multiple channels

This commit is contained in:
2026-02-06 17:21:37 +01:00
parent 6fdadbec28
commit 099b321b79
9 changed files with 539 additions and 105 deletions

View File

@@ -111,6 +111,12 @@ app.get(
return;
}
if (await prisma.channel.count({ where: { name: username } }) === 0) {
// channel doesn't exist
ws.close();
return;
}
ws.targetUsername = username;
ws.chatUser = chatUser;
ws.personalChannel = personalChannel;

View File

@@ -17,7 +17,9 @@
"db:migrate": "pnpm --filter=@hctv/db db:migrate",
"ui:add": "pnpm --filter=@hctv/web ui:add",
"prisma": "pnpm --filter=@hctv/db prisma",
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0"
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0",
"sdk:test": "dotenvx run -f .env.sdk -- pnpm --filter=@hctv/sdk test",
"sdk:example": "dotenvx run -f .env.sdk -- pnpm --filter=@hctv/sdk example"
},
"devDependencies": {
"prettier": "^3.6.2",

View File

@@ -1,2 +1,10 @@
# ts-lib-boilerplate
A simple typescript library boilerplate with testing and useful stuff
# @hctv/sdk
sdk for hctv, the live streaming platform for hack club.
check https://docs.hackclub.tv for sdk api documentation.
check the /examples directory for example usage of the sdk.
## author
gpl3 by @srizan10 and claude

View File

@@ -9,7 +9,7 @@ if (!aiToken) {
throw new Error('AI_TOKEN environment variable is required');
}
const sdk = new HctvSdk({ botToken: botToken })
const sdk = new HctvSdk({ botToken })
await sdk.chat.connect('bot-playground')
console.log('connected to the chat!')

View File

@@ -0,0 +1,39 @@
import { HctvSdk } from '../src/index.js';
const sdk = new HctvSdk({
botToken: process.env.BOT_TOKEN!,
});
await sdk.chat.connect('channel1');
await sdk.chat.connect('channel2');
await sdk.chat.connect('channel3');
console.log(`connected to ${sdk.chat.connectedChannels.join(', ')}`);
// gets messages from all channels I'm connected to
sdk.chat.onMessage((message) => {
console.log(`[${message.channelName}] ${message.username}: ${message.message}`);
});
// specifically handle messages from channel1
sdk.chat.onMessage((message) => {
console.log(`ts from channel1: ${message.message}`);
}, 'channel1');
sdk.chat.sendMessage('this is channel1!', 'channel1');
sdk.chat.sendMessage('this is channel2!', 'channel2');
console.log(`connected to channel1? ${sdk.chat.isConnectedTo('channel1')}`);
console.log(`connected to channel2? ${sdk.chat.isConnectedTo('channel2')}`);
// disconnect from channel2 after 5 seconds
setTimeout(() => {
console.log('disconnecting from channel2...');
sdk.chat.disconnect('channel2');
console.log(`still connected to: ${sdk.chat.connectedChannels.join(', ')}`);
}, 5000);
// disconnect from all channels
setTimeout(() => {
console.log('disconnecting from all channels...');
sdk.chat.disconnect();
}, 10000);

View File

@@ -4,7 +4,8 @@
"scripts": {
"test": "vitest run",
"build": "tsup",
"dev": "tsup --watch"
"dev": "tsup --watch",
"example": "sh -c 'bun run examples/${0}.ts'"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",

View File

@@ -11,15 +11,22 @@ import type {
const DEFAULT_BASE_URL = 'wss://hackclub.tv/api/stream/chat/ws';
const PING_INTERVAL = 20000; // 20 seconds
interface ChannelConnection {
ws: WebSocket;
pingInterval: ReturnType<typeof setInterval>;
messageHandlers: Set<MessageHandler>;
systemMessageHandlers: Set<SystemMessageHandler>;
historyHandlers: Set<HistoryHandler>;
}
export class ChatClient {
private botToken: string;
private baseUrl: string;
private ws: WebSocket | null = null;
private messageHandlers: Set<MessageHandler> = new Set();
private systemMessageHandlers: Set<SystemMessageHandler> = new Set();
private historyHandlers: Set<HistoryHandler> = new Set();
private channelName: string | null = null;
private pingInterval: ReturnType<typeof setInterval> | null = null;
private connections: Map<string, ChannelConnection> = new Map();
// Global handlers (receive messages from all channels)
private globalMessageHandlers: Set<MessageHandler> = new Set();
private globalSystemMessageHandlers: Set<SystemMessageHandler> = new Set();
private globalHistoryHandlers: Set<HistoryHandler> = new Set();
constructor(botToken: string, options?: ChatClientOptions) {
this.botToken = botToken;
@@ -27,65 +34,78 @@ export class ChatClient {
}
async connect(channelName: string): Promise<void> {
if (this.isConnected) {
return Promise.reject(new Error('already connected, please disconnect from it first'));
if (this.connections.has(channelName)) {
return Promise.reject(new Error(`already connected to channel: ${channelName}`));
}
this.channelName = channelName;
const wsUrl = `${this.baseUrl}/${channelName}?botAuth=${this.botToken}`;
let ws: WebSocket;
if (typeof process !== 'undefined' && process.versions?.node) {
const { default: WebSocket } = await import('ws');
this.ws = new WebSocket(wsUrl) as any;
ws = new WebSocket(wsUrl) as any;
} else {
this.ws = new WebSocket(wsUrl);
ws = new WebSocket(wsUrl);
}
return this.setupWebSocket(channelName);
const connection: ChannelConnection = {
ws,
pingInterval: null as any,
messageHandlers: new Set(),
systemMessageHandlers: new Set(),
historyHandlers: new Set(),
};
this.connections.set(channelName, connection);
return this.setupWebSocket(channelName, connection);
}
private setupWebSocket(channelName: string): Promise<void> {
private setupWebSocket(channelName: string, connection: ChannelConnection): Promise<void> {
return new Promise((resolve, reject) => {
this.ws!.onopen = () => {
this.emit('system', {
connection.ws.onopen = () => {
const systemMsg: SystemMessage = {
type: 'connected',
channelName,
message: 'Connected',
timestamp: Date.now(),
});
this.startPingInterval();
};
this.emitSystem(systemMsg, connection);
this.startPingInterval(channelName, connection);
resolve();
};
this.ws!.onmessage = (event) => {
connection.ws.onmessage = (event) => {
const data = JSON.parse(event.data.toString());
this.handleMessage(data, channelName);
this.handleMessage(data, channelName, connection);
};
this.ws!.onerror = () => {
this.emit('system', {
connection.ws.onerror = () => {
const systemMsg: SystemMessage = {
type: 'error',
channelName,
message: 'WebSocket error',
timestamp: Date.now(),
});
};
this.emitSystem(systemMsg, connection);
reject(new Error('WebSocket error'));
};
this.ws!.onclose = () => {
this.stopPingInterval();
this.emit('system', {
connection.ws.onclose = () => {
this.stopPingInterval(connection);
const systemMsg: SystemMessage = {
type: 'disconnected',
channelName,
message: 'Disconnected',
timestamp: Date.now(),
});
this.ws = null;
};
this.emitSystem(systemMsg, connection);
this.connections.delete(channelName);
};
});
}
private handleMessage(data: any, channelName: string): void {
private handleMessage(data: any, channelName: string, connection: ChannelConnection): void {
// Handle pong response
if (data.type === 'pong') {
return;
@@ -96,7 +116,10 @@ export class ChatClient {
const messages: ChatMessage[] = data.messages.map((msg: ServerChatMessage) =>
this.parseServerMessage(msg, channelName)
);
this.historyHandlers.forEach((handler) => handler(messages));
// Emit to channel-specific handlers
connection.historyHandlers.forEach((handler) => handler(messages));
// Emit to global handlers
this.globalHistoryHandlers.forEach((handler) => handler(messages));
return;
}
@@ -104,7 +127,7 @@ export class ChatClient {
// Server sends: { user: { id, username, pfpUrl, displayName?, isBot }, message }
if (data.user && typeof data.message === 'string') {
const chatMessage = this.parseServerMessage(data, channelName);
this.emit('message', chatMessage);
this.emitMessage(chatMessage, connection);
return;
}
@@ -129,63 +152,139 @@ export class ChatClient {
};
}
private startPingInterval(): void {
this.pingInterval = setInterval(() => {
if (this.isConnected) {
this.ws!.send(JSON.stringify({ type: 'ping' }));
private startPingInterval(channelName: string, connection: ChannelConnection): void {
connection.pingInterval = setInterval(() => {
if (connection.ws.readyState === WebSocket.OPEN) {
connection.ws.send(JSON.stringify({ type: 'ping' }));
}
}, PING_INTERVAL);
}
private stopPingInterval(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
private stopPingInterval(connection: ChannelConnection): void {
if (connection.pingInterval) {
clearInterval(connection.pingInterval);
}
}
disconnect(): void {
this.stopPingInterval();
this.ws?.close();
this.ws = null;
this.channelName = null;
}
sendMessage(message: string): void {
if (!this.isConnected) {
throw new Error('Not connected to a channel');
disconnect(channelName?: string): void {
if (channelName) {
// Disconnect specific channel
const connection = this.connections.get(channelName);
if (connection) {
this.stopPingInterval(connection);
connection.ws.close();
this.connections.delete(channelName);
}
} else {
// Disconnect all channels
for (const [name, connection] of this.connections) {
this.stopPingInterval(connection);
connection.ws.close();
}
this.connections.clear();
}
this.ws!.send(JSON.stringify({ type: 'message', message }));
}
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
sendMessage(message: string, channelName?: string): void {
if (channelName) {
// Send to specific channel
const connection = this.connections.get(channelName);
if (!connection || connection.ws.readyState !== WebSocket.OPEN) {
throw new Error(`Not connected to channel: ${channelName}`);
}
connection.ws.send(JSON.stringify({ type: 'message', message }));
} else {
// Send to first connected channel (backward compatibility)
const firstConnection = Array.from(this.connections.values())[0];
if (!firstConnection || firstConnection.ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected to any channel');
}
firstConnection.ws.send(JSON.stringify({ type: 'message', message }));
}
}
onSystemMessage(handler: SystemMessageHandler): () => void {
this.systemMessageHandlers.add(handler);
return () => this.systemMessageHandlers.delete(handler);
onMessage(handler: MessageHandler, channelName?: string): () => void {
if (channelName) {
// Channel-specific handler
const connection = this.connections.get(channelName);
if (!connection) {
throw new Error(`Not connected to channel: ${channelName}`);
}
connection.messageHandlers.add(handler);
return () => connection.messageHandlers.delete(handler);
} else {
// Global handler (receives from all channels)
this.globalMessageHandlers.add(handler);
return () => this.globalMessageHandlers.delete(handler);
}
}
onHistory(handler: HistoryHandler): () => void {
this.historyHandlers.add(handler);
return () => this.historyHandlers.delete(handler);
onSystemMessage(handler: SystemMessageHandler, channelName?: string): () => void {
if (channelName) {
// Channel-specific handler
const connection = this.connections.get(channelName);
if (!connection) {
throw new Error(`Not connected to channel: ${channelName}`);
}
connection.systemMessageHandlers.add(handler);
return () => connection.systemMessageHandlers.delete(handler);
} else {
// Global handler (receives from all channels)
this.globalSystemMessageHandlers.add(handler);
return () => this.globalSystemMessageHandlers.delete(handler);
}
}
private emit(type: 'message', data: ChatMessage): void;
private emit(type: 'system', data: SystemMessage): void;
private emit(type: 'message' | 'system', data: ChatMessage | SystemMessage): void {
const handlers = type === 'message' ? this.messageHandlers : this.systemMessageHandlers;
handlers.forEach((handler) => handler(data as any));
onHistory(handler: HistoryHandler, channelName?: string): () => void {
if (channelName) {
// Channel-specific handler
const connection = this.connections.get(channelName);
if (!connection) {
throw new Error(`Not connected to channel: ${channelName}`);
}
connection.historyHandlers.add(handler);
return () => connection.historyHandlers.delete(handler);
} else {
// Global handler (receives from all channels)
this.globalHistoryHandlers.add(handler);
return () => this.globalHistoryHandlers.delete(handler);
}
}
private emitMessage(message: ChatMessage, connection: ChannelConnection): void {
// Emit to channel-specific handlers
connection.messageHandlers.forEach((handler) => handler(message));
// Emit to global handlers
this.globalMessageHandlers.forEach((handler) => handler(message));
}
private emitSystem(message: SystemMessage, connection: ChannelConnection): void {
// Emit to channel-specific handlers
connection.systemMessageHandlers.forEach((handler) => handler(message));
// Emit to global handlers
this.globalSystemMessageHandlers.forEach((handler) => handler(message));
}
isConnectedTo(channelName: string): boolean {
const connection = this.connections.get(channelName);
return connection ? connection.ws.readyState === WebSocket.OPEN : false;
}
get connectedChannels(): string[] {
return Array.from(this.connections.keys());
}
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
return (
this.connections.size > 0 &&
Array.from(this.connections.values()).some((c) => c.ws.readyState === WebSocket.OPEN)
);
}
get currentChannel(): string | null {
return this.channelName;
// Return first connected channel for backward compatibility
const channels = Array.from(this.connections.keys());
return channels.length > 0 ? channels[0] : null;
}
}

View File

@@ -44,24 +44,29 @@ class MockWebSocket {
}
}
let mockWebSocketInstance: MockWebSocket | null = null;
let mockWebSocketInstances: MockWebSocket[] = [];
vi.stubGlobal(
'WebSocket',
class extends MockWebSocket {
constructor(url: string) {
super(url);
mockWebSocketInstance = this;
mockWebSocketInstances.push(this);
}
}
);
// Helper to get mock instance by channel name
function getMockInstance(channelName: string): MockWebSocket | undefined {
return mockWebSocketInstances.find((ws) => ws.url.includes(`/${channelName}`));
}
// Mock process to simulate browser environment (avoid Node.js ws import path)
vi.stubGlobal('process', { versions: {} });
describe('HctvSdk', () => {
beforeEach(() => {
mockWebSocketInstance = null;
mockWebSocketInstances = [];
});
afterEach(() => {
@@ -89,7 +94,7 @@ describe('ChatClient', () => {
let client: ChatClient;
beforeEach(() => {
mockWebSocketInstance = null;
mockWebSocketInstances = [];
client = new ChatClient('test-bot-token');
});
@@ -118,15 +123,25 @@ describe('ChatClient', () => {
await connectPromise;
expect(client.isConnected).toBe(true);
expect(mockWebSocketInstance).not.toBeNull();
expect(mockWebSocketInstance?.url).toContain('/ws/testchannel');
const mockWs = getMockInstance('testchannel');
expect(mockWs).not.toBeUndefined();
expect(mockWs?.url).toContain('/ws/testchannel');
});
it('should throw error when already connected', async () => {
it('should allow connecting to multiple channels', async () => {
await client.connect('channel1');
await client.connect('channel2');
expect(client.isConnectedTo('channel1')).toBe(true);
expect(client.isConnectedTo('channel2')).toBe(true);
expect(client.connectedChannels).toEqual(['channel1', 'channel2']);
});
it('should throw error when already connected to same channel', async () => {
await client.connect('testchannel');
await expect(client.connect('anotherchannel')).rejects.toThrow(
'already connected, please disconnect from it first'
await expect(client.connect('testchannel')).rejects.toThrow(
'already connected to channel: testchannel'
);
});
@@ -146,14 +161,28 @@ describe('ChatClient', () => {
});
describe('disconnect', () => {
it('should disconnect from channel', async () => {
await client.connect('testchannel');
it('should disconnect from specific channel', async () => {
await client.connect('channel1');
await client.connect('channel2');
expect(client.isConnectedTo('channel1')).toBe(true);
expect(client.isConnectedTo('channel2')).toBe(true);
client.disconnect('channel1');
expect(client.isConnectedTo('channel1')).toBe(false);
expect(client.isConnectedTo('channel2')).toBe(true);
});
it('should disconnect from all channels when no channel specified', async () => {
await client.connect('channel1');
await client.connect('channel2');
expect(client.isConnected).toBe(true);
client.disconnect();
expect(client.isConnected).toBe(false);
expect(client.currentChannel).toBeNull();
expect(client.connectedChannels.length).toBe(0);
});
it('should emit disconnected system message', async () => {
@@ -172,31 +201,52 @@ describe('ChatClient', () => {
});
describe('sendMessage', () => {
it('should send a message when connected', async () => {
it('should send a message to specific channel', async () => {
await client.connect('testchannel');
client.sendMessage('Hello, world!', 'testchannel');
const mockWs = getMockInstance('testchannel');
const messages = mockWs!.sentMessages;
const lastMsg = JSON.parse(messages[messages.length - 1]);
expect(lastMsg.type).toBe('message');
expect(lastMsg.message).toBe('Hello, world!');
});
it('should send to first channel when no channel specified', async () => {
await client.connect('testchannel');
client.sendMessage('Hello, world!');
const messages = mockWebSocketInstance!.sentMessages;
const mockWs = getMockInstance('testchannel');
const messages = mockWs!.sentMessages;
const lastMsg = JSON.parse(messages[messages.length - 1]);
expect(lastMsg.type).toBe('message');
expect(lastMsg.message).toBe('Hello, world!');
});
it('should throw error when not connected', () => {
expect(() => client.sendMessage('test')).toThrow('Not connected to a channel');
expect(() => client.sendMessage('test')).toThrow('Not connected to any channel');
});
it('should throw error when channel not connected', async () => {
await client.connect('channel1');
expect(() => client.sendMessage('test', 'channel2')).toThrow(
'Not connected to channel: channel2'
);
});
});
describe('onMessage', () => {
it('should call handler when message received (server format)', async () => {
it('should call global handler when message received', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
const mockWs = getMockInstance('testchannel');
// Server format: { user: {...}, message: string }
mockWebSocketInstance?.simulateMessage({
mockWs?.simulateMessage({
user: {
id: 'user-123',
username: 'testuser',
@@ -214,6 +264,80 @@ describe('ChatClient', () => {
);
});
it('should call channel-specific handler', async () => {
await client.connect('testchannel');
const channelHandler = vi.fn();
client.onMessage(channelHandler, 'testchannel');
const mockWs = getMockInstance('testchannel');
mockWs?.simulateMessage({
user: { id: '1', username: 'testuser', pfpUrl: '' },
message: 'Hello',
});
expect(channelHandler).toHaveBeenCalledWith(expect.objectContaining({ message: 'Hello' }));
});
it('should route messages to correct channel handler', async () => {
await client.connect('channel1');
await client.connect('channel2');
const handler1 = vi.fn();
const handler2 = vi.fn();
client.onMessage(handler1, 'channel1');
client.onMessage(handler2, 'channel2');
const mockWs1 = getMockInstance('channel1');
const mockWs2 = getMockInstance('channel2');
mockWs1?.simulateMessage({
user: { id: '1', username: 'user1', pfpUrl: '' },
message: 'Message to channel1',
});
mockWs2?.simulateMessage({
user: { id: '2', username: 'user2', pfpUrl: '' },
message: 'Message to channel2',
});
expect(handler1).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Message to channel1', channelName: 'channel1' })
);
expect(handler2).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Message to channel2', channelName: 'channel2' })
);
});
it('should call global handler for all channels', async () => {
const globalHandler = vi.fn();
client.onMessage(globalHandler);
await client.connect('channel1');
await client.connect('channel2');
const mockWs1 = getMockInstance('channel1');
const mockWs2 = getMockInstance('channel2');
mockWs1?.simulateMessage({
user: { id: '1', username: 'user1', pfpUrl: '' },
message: 'Message 1',
});
mockWs2?.simulateMessage({
user: { id: '2', username: 'user2', pfpUrl: '' },
message: 'Message 2',
});
expect(globalHandler).toHaveBeenCalledTimes(2);
expect(globalHandler).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Message 1', channelName: 'channel1' })
);
expect(globalHandler).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Message 2', channelName: 'channel2' })
);
});
it('should return unsubscribe function', async () => {
const messageHandler = vi.fn();
const unsubscribe = client.onMessage(messageHandler);
@@ -222,7 +346,8 @@ describe('ChatClient', () => {
unsubscribe();
mockWebSocketInstance?.simulateMessage({
const mockWs = getMockInstance('testchannel');
mockWs?.simulateMessage({
user: { id: '1', username: 'testuser', pfpUrl: '' },
message: 'Should not receive',
});
@@ -239,7 +364,8 @@ describe('ChatClient', () => {
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
const mockWs = getMockInstance('testchannel');
mockWs?.simulateMessage({
user: { id: '1', username: 'testuser', pfpUrl: '' },
message: 'test message',
});
@@ -256,7 +382,7 @@ describe('ChatClient', () => {
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
type: 'history',
messages: [
{
@@ -287,7 +413,7 @@ describe('ChatClient', () => {
await client.connect('testchannel');
unsubscribe();
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
type: 'history',
messages: [],
});
@@ -329,7 +455,7 @@ describe('ChatClient', () => {
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
user: {
id: 'user-123',
username: 'johndoe',
@@ -356,7 +482,7 @@ describe('ChatClient', () => {
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
user: {
id: 'bot-123',
username: 'mybot',
@@ -383,7 +509,7 @@ describe('ChatClient', () => {
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
type: 'pong',
});
@@ -398,7 +524,7 @@ describe('ChatClient', () => {
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
type: 'emojiMsgResponse',
emojis: {
smile: 'https://example.com/emoji/smile.png',
@@ -414,7 +540,7 @@ describe('ChatClient', () => {
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
type: 'emojiSearchResponse',
results: ['smile', 'smirk'],
});
@@ -466,7 +592,7 @@ describe('ChatClient', () => {
await new Promise((resolve) => setTimeout(resolve, 5));
mockWebSocketInstance?.simulateError(new Error('Connection failed'));
getMockInstance('testchannel')?.simulateError(new Error('Connection failed'));
await expect(connectPromise).rejects.toBeDefined();
@@ -550,11 +676,164 @@ describe('SystemMessage type', () => {
});
});
describe('Multi-channel support', () => {
let client: ChatClient;
beforeEach(() => {
mockWebSocketInstances = [];
client = new ChatClient('test-bot-token');
});
afterEach(() => {
client.disconnect();
vi.clearAllMocks();
});
it('should connect to multiple channels simultaneously', async () => {
await client.connect('channel1');
await client.connect('channel2');
await client.connect('channel3');
expect(client.connectedChannels).toEqual(['channel1', 'channel2', 'channel3']);
expect(client.isConnectedTo('channel1')).toBe(true);
expect(client.isConnectedTo('channel2')).toBe(true);
expect(client.isConnectedTo('channel3')).toBe(true);
});
it('should send messages to specific channels', async () => {
await client.connect('channel1');
await client.connect('channel2');
client.sendMessage('Message 1', 'channel1');
client.sendMessage('Message 2', 'channel2');
const mockWs1 = getMockInstance('channel1');
const mockWs2 = getMockInstance('channel2');
const msg1 = JSON.parse(mockWs1!.sentMessages[mockWs1!.sentMessages.length - 1]);
const msg2 = JSON.parse(mockWs2!.sentMessages[mockWs2!.sentMessages.length - 1]);
expect(msg1.message).toBe('Message 1');
expect(msg2.message).toBe('Message 2');
});
it('should route messages correctly with channel-specific handlers', async () => {
await client.connect('channel1');
await client.connect('channel2');
const handler1 = vi.fn();
const handler2 = vi.fn();
client.onMessage(handler1, 'channel1');
client.onMessage(handler2, 'channel2');
const mockWs1 = getMockInstance('channel1');
const mockWs2 = getMockInstance('channel2');
mockWs1?.simulateMessage({
user: { id: '1', username: 'user1', pfpUrl: '' },
message: 'Hello channel 1',
});
mockWs2?.simulateMessage({
user: { id: '2', username: 'user2', pfpUrl: '' },
message: 'Hello channel 2',
});
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Hello channel 1',
channelName: 'channel1',
})
);
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Hello channel 2',
channelName: 'channel2',
})
);
});
it('should support global handlers receiving from all channels', async () => {
const globalHandler = vi.fn();
client.onMessage(globalHandler);
await client.connect('channel1');
await client.connect('channel2');
const mockWs1 = getMockInstance('channel1');
const mockWs2 = getMockInstance('channel2');
mockWs1?.simulateMessage({
user: { id: '1', username: 'user1', pfpUrl: '' },
message: 'Message from channel 1',
});
mockWs2?.simulateMessage({
user: { id: '2', username: 'user2', pfpUrl: '' },
message: 'Message from channel 2',
});
expect(globalHandler).toHaveBeenCalledTimes(2);
});
it('should disconnect from specific channel without affecting others', async () => {
await client.connect('channel1');
await client.connect('channel2');
await client.connect('channel3');
client.disconnect('channel2');
expect(client.isConnectedTo('channel1')).toBe(true);
expect(client.isConnectedTo('channel2')).toBe(false);
expect(client.isConnectedTo('channel3')).toBe(true);
expect(client.connectedChannels).toEqual(['channel1', 'channel3']);
});
it('should handle history for multiple channels independently', async () => {
const historyHandler = vi.fn();
client.onHistory(historyHandler);
await client.connect('channel1');
await client.connect('channel2');
const mockWs1 = getMockInstance('channel1');
const mockWs2 = getMockInstance('channel2');
mockWs1?.simulateMessage({
type: 'history',
messages: [{ user: { id: '1', username: 'u1', pfpUrl: '' }, message: 'C1 History' }],
});
mockWs2?.simulateMessage({
type: 'history',
messages: [{ user: { id: '2', username: 'u2', pfpUrl: '' }, message: 'C2 History' }],
});
expect(historyHandler).toHaveBeenCalledTimes(2);
});
it('should maintain backward compatibility with single channel usage', async () => {
await client.connect('testchannel');
expect(client.isConnected).toBe(true);
expect(client.currentChannel).toBe('testchannel');
client.sendMessage('Test');
const mockWs = getMockInstance('testchannel');
expect(mockWs!.sentMessages.length).toBeGreaterThan(0);
});
});
describe('Edge cases', () => {
let client: ChatClient;
beforeEach(() => {
mockWebSocketInstance = null;
mockWebSocketInstances = [];
client = new ChatClient('test-bot-token');
});
@@ -569,7 +848,7 @@ describe('Edge cases', () => {
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
user: { id: '1', username: 'testuser', pfpUrl: '' },
message: '',
});
@@ -588,7 +867,7 @@ describe('Edge cases', () => {
await client.connect('testchannel');
const specialMessage = '🎉 Hello <script>alert("xss")</script> & "quotes" \'apostrophe\'';
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
user: { id: '1', username: 'testuser', pfpUrl: '' },
message: specialMessage,
});
@@ -607,7 +886,7 @@ describe('Edge cases', () => {
await client.connect('testchannel');
const longMessage = 'a'.repeat(10000);
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
user: { id: '1', username: 'testuser', pfpUrl: '' },
message: longMessage,
});
@@ -626,7 +905,7 @@ describe('Edge cases', () => {
await client.connect('testchannel');
for (let i = 0; i < 100; i++) {
mockWebSocketInstance?.simulateMessage({
getMockInstance('testchannel')?.simulateMessage({
user: { id: '1', username: 'testuser', pfpUrl: '' },
message: `Message ${i}`,
});

View File

@@ -190,7 +190,7 @@ describe.skipIf(!BOT_TOKEN)('ChatClient Integration Tests', () => {
}, 15000);
it('should throw when sending message while disconnected', () => {
expect(() => client.sendMessage('test')).toThrow('Not connected to a channel');
expect(() => client.sendMessage('test')).toThrow('Not connected to any channel');
});
});
});