mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat: support connecting to multiple channels
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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!')
|
||||
|
||||
39
packages/sdk/examples/multi-channel.ts
Normal file
39
packages/sdk/examples/multi-channel.ts
Normal 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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user