feat: preliminary chat api

This commit is contained in:
2026-01-30 16:42:50 +01:00
parent 5b6addac9a
commit 221aff0050
8 changed files with 988 additions and 76 deletions

View File

@@ -26,6 +26,7 @@
"author": "Izan Gil <npm@srizan.dev>",
"license": "MIT",
"devDependencies": {
"@types/node": "^25.1.0",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"eslint": "^9.39.2",

94
packages/sdk/src/chat.ts Normal file
View File

@@ -0,0 +1,94 @@
// most code here has been written by claude opus 4.5
import type { ChatMessage, MessageHandler, SystemMessage, SystemMessageHandler } from './types';
export class ChatClient {
private botToken: string;
private ws: WebSocket | null = null;
private messageHandlers: Set<MessageHandler> = new Set();
private systemMessageHandlers: Set<SystemMessageHandler> = new Set();
private channelName: string | null = null;
constructor(botToken: string) {
this.botToken = botToken;
}
connect(channelName: string): Promise<void> {
if (this.isConnected) {
return Promise.reject(new Error('Already connected. Disconnect first.'));
}
this.channelName = channelName;
const wsUrl = `${process.env.CHAT_WS_URL || 'ws://localhost:3001'}/ws`;
this.ws = new WebSocket(wsUrl);
return new Promise((resolve, reject) => {
this.ws!.onopen = () => {
this.ws!.send(JSON.stringify({ type: 'auth', token: this.botToken, channelName }));
this.emit('system', { type: 'connected', channelName, message: `Connected to ${channelName}`, timestamp: Date.now() });
resolve();
};
this.ws!.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'message' && typeof data.message === 'string') {
this.emit('message', {
id: data.id || `${Date.now()}-${Math.random()}`,
channelName: data.channelName || channelName,
username: data.username || data.user?.username || 'Unknown',
message: data.message,
timestamp: data.timestamp || Date.now(),
type: data.messageType || 'message',
});
}
};
this.ws!.onerror = () => {
this.emit('system', { type: 'error', channelName, message: 'WebSocket error', timestamp: Date.now() });
reject(new Error('WebSocket error'));
};
this.ws!.onclose = () => {
this.emit('system', { type: 'disconnected', channelName, message: 'Disconnected', timestamp: Date.now() });
this.ws = null;
};
});
}
disconnect(): void {
this.ws?.close();
this.ws = null;
this.channelName = null;
}
sendMessage(message: string): void {
if (!this.isConnected) {
throw new Error('Not connected to a channel');
}
this.ws!.send(JSON.stringify({ type: 'message', message, channelName: this.channelName }));
}
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
onSystemMessage(handler: SystemMessageHandler): () => void {
this.systemMessageHandlers.add(handler);
return () => this.systemMessageHandlers.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));
}
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
get currentChannel(): string | null {
return this.channelName;
}
}

View File

@@ -1,21 +1,18 @@
import { ChatClient } from './chat.js';
import type { ChatMessage, MessageHandler } from './types.js';
export class HctvSdk {
private botToken: string
private botToken: string;
public chat: ChatClient;
constructor(args: ConstructorArgs) {
this.botToken = args.botToken
this.botToken = args.botToken;
this.chat = new ChatClient(args.botToken);
}
}
/*
const client = new HctvSdk({ botToken: 'hctvb_asddfasdfasdfasdfasdf' });
await client.chat.connect('channelName');
client.chat.onMessage((message) => {
// message would include data like the channelname etc
console.log('New message:', message);
});
*/
interface ConstructorArgs {
botToken: string;
}
export { ChatClient } from './chat.js';
export type { ChatMessage, MessageHandler } from './types.js';

18
packages/sdk/src/types.ts Normal file
View File

@@ -0,0 +1,18 @@
export interface ChatMessage {
id: string;
channelName: string;
username: string;
message: string;
timestamp: number;
type: 'message' | 'systemMsg';
}
export interface SystemMessage {
type: 'connected' | 'disconnected' | 'error';
channelName: string;
message: string;
timestamp: number;
}
export type MessageHandler = (message: ChatMessage) => void;
export type SystemMessageHandler = (message: SystemMessage) => void;

View File

@@ -0,0 +1,793 @@
// testing completely controlled by claude opus 4.5 because i'm lazy as heck
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HctvSdk, ChatClient } from '../src/index.js';
import type { ChatMessage, SystemMessage } from '../src/types.js';
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((error: any) => void) | null = null;
onclose: (() => void) | null = null;
sentMessages: string[] = [];
url: string;
constructor(url: string) {
this.url = url;
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
this.onopen?.();
}, 10);
}
send(data: string) {
this.sentMessages.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.();
}
simulateMessage(data: any) {
this.onmessage?.({ data: JSON.stringify(data) });
}
simulateError(error: any) {
this.onerror?.(error);
}
}
let mockWebSocketInstance: MockWebSocket | null = null;
vi.stubGlobal('WebSocket', class extends MockWebSocket {
constructor(url: string) {
super(url);
mockWebSocketInstance = this;
}
});
describe('HctvSdk', () => {
beforeEach(() => {
mockWebSocketInstance = null;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with bot token', () => {
const sdk = new HctvSdk({ botToken: 'test-token' });
expect(sdk).toBeDefined();
expect(sdk.chat).toBeInstanceOf(ChatClient);
});
});
});
describe('ChatClient', () => {
let client: ChatClient;
beforeEach(() => {
mockWebSocketInstance = null;
client = new ChatClient('test-bot-token');
});
afterEach(() => {
client.disconnect();
vi.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with bot token', () => {
expect(client).toBeDefined();
expect(client.isConnected).toBe(false);
expect(client.currentChannel).toBeNull();
});
});
describe('connect', () => {
it('should connect to a channel', async () => {
const connectPromise = client.connect('testchannel');
await connectPromise;
expect(client.isConnected).toBe(true);
expect(mockWebSocketInstance).not.toBeNull();
expect(mockWebSocketInstance?.url).toContain('/ws');
});
it('should send auth message on connect', async () => {
await client.connect('testchannel');
expect(mockWebSocketInstance?.sentMessages.length).toBeGreaterThan(0);
const authMsg = JSON.parse(mockWebSocketInstance!.sentMessages[0]);
expect(authMsg.type).toBe('auth');
expect(authMsg.token).toBe('test-bot-token');
expect(authMsg.channelName).toBe('testchannel');
});
it('should throw error when already connected', async () => {
await client.connect('testchannel');
await expect(client.connect('anotherchannel')).rejects.toThrow(
'Already connected to a channel'
);
});
it('should emit connected system message', async () => {
const systemHandler = vi.fn();
client.onSystemMessage(systemHandler);
await client.connect('testchannel');
expect(systemHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'connected',
channelName: 'testchannel',
})
);
});
});
describe('disconnect', () => {
it('should disconnect from channel', async () => {
await client.connect('testchannel');
expect(client.isConnected).toBe(true);
client.disconnect();
expect(client.isConnected).toBe(false);
expect(client.currentChannel).toBeNull();
});
it('should emit disconnected system message', async () => {
const systemHandler = vi.fn();
client.onSystemMessage(systemHandler);
await client.connect('testchannel');
client.disconnect();
expect(systemHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'disconnected',
})
);
});
});
describe('sendMessage', () => {
it('should send a message when connected', async () => {
await client.connect('testchannel');
client.sendMessage('Hello, world!');
const messages = mockWebSocketInstance!.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');
});
});
describe('onMessage', () => {
it('should call handler when message received', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: 'Hello from server',
user: {
id: 'user-123',
username: 'testuser',
pfpUrl: 'https://example.com/pfp.jpg',
},
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Hello from server',
username: 'testuser',
})
);
});
it('should return unsubscribe function', async () => {
const messageHandler = vi.fn();
const unsubscribe = client.onMessage(messageHandler);
await client.connect('testchannel');
unsubscribe();
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: 'Should not receive',
user: { username: 'testuser' },
});
expect(messageHandler).not.toHaveBeenCalled();
});
it('should handle multiple message handlers', async () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
client.onMessage(handler1);
client.onMessage(handler2);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: 'test message',
user: { username: 'testuser' },
});
expect(handler1).toHaveBeenCalled();
expect(handler2).toHaveBeenCalled();
});
});
describe('onSystemMessage', () => {
it('should call handler for system events', async () => {
const systemHandler = vi.fn();
client.onSystemMessage(systemHandler);
await client.connect('testchannel');
expect(systemHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'connected',
})
);
});
it('should return unsubscribe function', async () => {
const systemHandler = vi.fn();
const unsubscribe = client.onSystemMessage(systemHandler);
unsubscribe();
await client.connect('testchannel');
expect(systemHandler).not.toHaveBeenCalled();
});
});
describe('message parsing', () => {
it('should handle message with full user object', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: 'Hello',
user: {
id: 'user-123',
username: 'johndoe',
pfpUrl: 'https://example.com/pfp.jpg',
displayName: 'John Doe',
isBot: false,
},
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
username: 'johndoe',
message: 'Hello',
})
);
});
it('should handle message with username directly', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: 'Direct username',
username: 'directuser',
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
username: 'directuser',
message: 'Direct username',
})
);
});
it('should ignore invalid messages', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
});
expect(messageHandler).not.toHaveBeenCalled();
});
it('should ignore non-message types', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'pong',
});
mockWebSocketInstance?.simulateMessage({
type: 'history',
messages: [],
});
expect(messageHandler).not.toHaveBeenCalled();
});
});
describe('currentChannel', () => {
it('should return null when not connected', () => {
expect(client.currentChannel).toBeNull();
});
it('should return channel name when connected', async () => {
await client.connect('mychannel');
expect(client.currentChannel).toBe('mychannel');
});
it('should return null after disconnect', async () => {
await client.connect('mychannel');
client.disconnect();
expect(client.currentChannel).toBeNull();
});
});
describe('isConnected', () => {
it('should return false initially', () => {
expect(client.isConnected).toBe(false);
});
it('should return true when connected', async () => {
await client.connect('testchannel');
expect(client.isConnected).toBe(true);
});
it('should return false after disconnect', async () => {
await client.connect('testchannel');
client.disconnect();
expect(client.isConnected).toBe(false);
});
});
describe('error handling', () => {
it('should emit error system message on WebSocket error', async () => {
const systemHandler = vi.fn();
client.onSystemMessage(systemHandler);
const connectPromise = client.connect('testchannel');
await new Promise(resolve => setTimeout(resolve, 5));
mockWebSocketInstance?.simulateError(new Error('Connection failed'));
await expect(connectPromise).rejects.toBeDefined();
expect(systemHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
})
);
});
it('should handle malformed message data gracefully', async () => {
const messageHandler = vi.fn();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.onmessage?.({ data: 'not valid json{' });
expect(messageHandler).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
});
describe('ChatMessage type', () => {
it('should have correct structure', () => {
const message: ChatMessage = {
id: 'msg-123',
channelName: 'testchannel',
username: 'testuser',
message: 'Hello, world!',
timestamp: Date.now(),
type: 'message',
};
expect(message.id).toBe('msg-123');
expect(message.channelName).toBe('testchannel');
expect(message.username).toBe('testuser');
expect(message.message).toBe('Hello, world!');
expect(typeof message.timestamp).toBe('number');
expect(message.type).toBe('message');
});
it('should support systemMsg type', () => {
const message: ChatMessage = {
id: 'sys-123',
channelName: 'testchannel',
username: 'system',
message: 'User joined',
timestamp: Date.now(),
type: 'systemMsg',
};
expect(message.type).toBe('systemMsg');
});
});
describe('SystemMessage type', () => {
it('should support connected type', () => {
const message: SystemMessage = {
type: 'connected',
channelName: 'testchannel',
message: 'Connected to testchannel',
timestamp: Date.now(),
};
expect(message.type).toBe('connected');
});
it('should support disconnected type', () => {
const message: SystemMessage = {
type: 'disconnected',
channelName: 'testchannel',
message: 'Disconnected from testchannel',
timestamp: Date.now(),
};
expect(message.type).toBe('disconnected');
});
it('should support error type', () => {
const message: SystemMessage = {
type: 'error',
channelName: 'testchannel',
message: 'An error occurred',
timestamp: Date.now(),
};
expect(message.type).toBe('error');
});
});
describe('Integration with Chat Server Protocol', () => {
let client: ChatClient;
beforeEach(() => {
mockWebSocketInstance = null;
client = new ChatClient('test-bot-token');
});
afterEach(() => {
client.disconnect();
vi.clearAllMocks();
});
describe('history messages', () => {
it('should handle history message from server', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'history',
messages: [
{
user: { id: 'u1', username: 'user1', pfpUrl: '' },
message: 'First message',
type: 'message',
},
{
user: { id: 'u2', username: 'user2', pfpUrl: '' },
message: 'Second message',
type: 'message',
},
],
});
expect(messageHandler).not.toHaveBeenCalled();
});
});
describe('ping/pong', () => {
it('should handle pong response from server', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'pong',
});
expect(messageHandler).not.toHaveBeenCalled();
});
});
describe('emoji responses', () => {
it('should handle emojiMsgResponse from server', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'emojiMsgResponse',
emojis: {
smile: 'https://example.com/emoji/smile.png',
laugh: 'https://example.com/emoji/laugh.png',
},
});
expect(messageHandler).not.toHaveBeenCalled();
});
it('should handle emojiSearchResponse from server', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'emojiSearchResponse',
results: ['smile', 'smirk', 'smiley'],
});
expect(messageHandler).not.toHaveBeenCalled();
});
});
describe('bot user messages', () => {
it('should handle messages from bot users', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: 'Hello from bot!',
user: {
id: 'bot-123',
username: 'mybot',
pfpUrl: 'https://example.com/bot-avatar.png',
displayName: 'My Bot',
isBot: true,
},
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
username: 'mybot',
message: 'Hello from bot!',
})
);
});
});
describe('message structure from server', () => {
it('should handle message structure matching chat server format', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
user: {
id: 'user-abc',
username: 'streamuser',
pfpUrl: 'https://example.com/pfp.jpg',
displayName: 'Stream User',
isBot: false,
},
message: 'Great stream!',
});
expect(messageHandler).not.toHaveBeenCalled();
});
it('should handle message with explicit type', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
user: {
id: 'user-abc',
username: 'streamuser',
pfpUrl: 'https://example.com/pfp.jpg',
},
message: 'Great stream!',
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
username: 'streamuser',
message: 'Great stream!',
})
);
});
});
});
describe('Edge cases', () => {
let client: ChatClient;
beforeEach(() => {
mockWebSocketInstance = null;
client = new ChatClient('test-bot-token');
});
afterEach(() => {
client.disconnect();
vi.clearAllMocks();
});
it('should handle empty message', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: '',
user: { username: 'testuser' },
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
message: '',
})
);
});
it('should handle message with special characters', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
const specialMessage = '🎉 Hello <script>alert("xss")</script> & "quotes" \'apostrophe\'';
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: specialMessage,
user: { username: 'testuser' },
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
message: specialMessage,
})
);
});
it('should handle very long messages', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
const longMessage = 'a'.repeat(10000);
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: longMessage,
user: { username: 'testuser' },
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
message: longMessage,
})
);
});
it('should handle unicode usernames', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: 'Hello',
user: { username: '日本語ユーザー' },
});
expect(messageHandler).toHaveBeenCalledWith(
expect.objectContaining({
username: '日本語ユーザー',
})
);
});
it('should handle rapid successive messages', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
for (let i = 0; i < 100; i++) {
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: `Message ${i}`,
user: { username: 'testuser' },
});
}
expect(messageHandler).toHaveBeenCalledTimes(100);
});
it('should handle errors in message handlers gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const errorHandler = vi.fn(() => {
throw new Error('Handler error');
});
const goodHandler = vi.fn();
client.onMessage(errorHandler);
client.onMessage(goodHandler);
await client.connect('testchannel');
mockWebSocketInstance?.simulateMessage({
type: 'message',
message: 'Test',
user: { username: 'testuser' },
});
expect(goodHandler).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should handle disconnect while message is being processed', async () => {
const messageHandler = vi.fn();
client.onMessage(messageHandler);
await client.connect('testchannel');
client.disconnect();
expect(() => client.sendMessage('test')).toThrow('Not connected');
});
});

View File

@@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('index', () => {
it('should pass', () => {
expect(1 + 1).toBe(2)
})
})

View File

@@ -49,13 +49,12 @@
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
"declarationMap": true, /* Create sourcemaps for d.ts files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
@@ -105,5 +104,12 @@
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}