Skip to main content

Socket.io Connection

This guide explains how the Angular frontend connects to Hexabot via Socket.io.

Overview

The frontend uses Socket.io Client to establish a real-time WebSocket connection with Hexabot's ChatGateway.

Installation

npm install socket.io-client

ChatService Implementation

src/app/services/chat.service.ts:

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { io, Socket } from 'socket.io-client';

export interface ChatMessage {
sender: 'user' | 'bot';
text: string;
timestamp: Date;
type: 'text' | 'buttons' | 'custom';
buttons?: any[];
quick_replies?: any[];
custom_payload?: any;
}

@Injectable({
providedIn: 'root'
})
export class ChatService {
private socket: Socket | undefined;
private readonly SOCKET_URL = 'http://localhost:4000';

// Observables
private messagesSubject = new BehaviorSubject<ChatMessage[]>([]);
public messages$ = this.messagesSubject.asObservable();

private connectionStatusSubject = new BehaviorSubject<boolean>(false);
public connectionStatus$ = this.connectionStatusSubject.asObservable();

private typingSubject = new BehaviorSubject<boolean>(false);
public typing$ = this.typingSubject.asObservable();

// Message history per agent
private history: { [key: string]: ChatMessage[] } = {
bgv: [],
listing: [],
loan: []
};

constructor(private authService: AuthService) {}

/**
* Connect to specific agent channel
*/
connect(agentId: 'bgv' | 'listing' | 'loan' = 'bgv') {
if (this.socket) {
this.disconnect();
}

const channelId = `prophunt_${agentId}`;
const userId = this.authService.getUserId() || 'guest';

console.log(`[ChatService] Connecting to ${channelId}`);

this.socket = io(this.SOCKET_URL, {
path: '/socket.io/',
transports: ['polling', 'websocket'],
upgrade: true,
withCredentials: true,
timeout: 20000,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000
});

// Connection events
this.socket.on('connect', () => {
console.log('✅ Connected to Hexabot');
this.connectionStatusSubject.next(true);

// Join agent channel
this.socket?.emit('join_channel', {
channel_id: channelId,
user_id: userId
});

console.log(`[ChatService] Joined ${channelId}`);
});

this.socket.on('connect_error', (error) => {
console.error('❌ Connection error:', error);
this.connectionStatusSubject.next(false);
});

this.socket.on('disconnect', (reason) => {
console.log('🔌 Disconnected:', reason);
this.connectionStatusSubject.next(false);
});

// Message events
this.socket.on('message', (data: any) => {
console.log('[ChatService] Received message:', data);
this.handleIncomingMessage(data, agentId);
});

// Typing indicator
this.socket.on('typing', (data: any) => {
this.typingSubject.next(data.isTyping || false);
});

// Load history for this agent
this.messagesSubject.next(this.history[agentId] || []);
}

/**
* Send message to active agent
*/
sendMessage(text: string, agentId: 'bgv' | 'listing' | 'loan') {
if (!this.socket) return;

const userId = this.authService.getUserId() || 'guest';
const channelId = `prophunt_${agentId}`;

const message: ChatMessage = {
sender: 'user',
text,
timestamp: new Date(),
type: 'text'
};

// Add to local history
this.addMessageToHistory(agentId, message);

// Send to Hexabot
this.socket.emit('message', {
channel_id: channelId,
user_id: userId,
text: text,
context: {
vars: {
active_agent: agentId
}
}
});
}

/**
* Send event (file upload, button click, etc.)
*/
sendEvent(type: string, metadata: any, agentId: 'bgv' | 'listing' | 'loan') {
if (!this.socket) return;

const userId = this.authService.getUserId() || 'guest';
const channelId = `prophunt_${agentId}`;

this.socket.emit('event', {
message: {
channel_id: channelId,
user_id: userId,
payload: {
type: type,
metadata: metadata
},
context: {
vars: {
active_agent: agentId
}
}
},
context: {}
});
}

/**
* Switch to different agent
*/
switchAgent(newAgentId: 'bgv' | 'listing' | 'loan') {
if (this.socket) {
const currentAgent = Object.keys(this.history).find(
key => this.messagesSubject.value === this.history[key]
);

if (currentAgent) {
this.socket.emit('leave_channel', {
channel_id: `prophunt_${currentAgent}`
});
}
}

this.connect(newAgentId);
}

/**
* Handle incoming message from Hexabot
*/
private handleIncomingMessage(data: any, agentId: string) {
this.typingSubject.next(false);

const envelope = data.message || data;

const message: ChatMessage = {
sender: 'bot',
text: envelope.text || '',
timestamp: new Date(),
type: envelope.type || 'text',
buttons: envelope.buttons,
quick_replies: envelope.quick_replies,
custom_payload: envelope.custom_payload
};

this.addMessageToHistory(agentId, message);
}

/**
* Add message to history
*/
private addMessageToHistory(agentId: string, message: ChatMessage) {
if (!this.history[agentId]) {
this.history[agentId] = [];
}
this.history[agentId].push(message);
this.messagesSubject.next([...this.history[agentId]]);
}

/**
* Disconnect from Hexabot
*/
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.connectionStatusSubject.next(false);
}
}
}

Component Usage

src/app/components/chat/chat.component.ts:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { ChatService, ChatMessage } from '../../services/chat.service';
import { Subscription } from 'rxjs';

@Component({
selector: 'app-chat',
templateUrl: './chat.component.html'
})
export class ChatComponent implements OnInit, OnDestroy {
messages: ChatMessage[] = [];
isConnected = false;
isTyping = false;
inputText = '';
currentAgent: 'bgv' | 'listing' | 'loan' = 'bgv';

private subscriptions = new Subscription();

agents = [
{ id: 'bgv', name: 'BGV', icon: '📋' },
{ id: 'loan', name: 'Loan', icon: '💰' },
{ id: 'listing', name: 'Listings', icon: '🏠' }
];

constructor(private chatService: ChatService) {}

ngOnInit() {
// Subscribe to messages
this.subscriptions.add(
this.chatService.messages$.subscribe(messages => {
this.messages = messages;
this.scrollToBottom();
})
);

// Subscribe to connection status
this.subscriptions.add(
this.chatService.connectionStatus$.subscribe(status => {
this.isConnected = status;
})
);

// Subscribe to typing indicator
this.subscriptions.add(
this.chatService.typing$.subscribe(typing => {
this.isTyping = typing;
})
);

// Connect to default agent
this.chatService.connect(this.currentAgent);
}

ngOnDestroy() {
this.subscriptions.unsubscribe();
this.chatService.disconnect();
}

sendMessage() {
if (!this.inputText.trim()) return;

this.chatService.sendMessage(this.inputText, this.currentAgent);
this.inputText = '';
}

selectAgent(agentId: 'bgv' | 'listing' | 'loan') {
this.currentAgent = agentId;
this.chatService.switchAgent(agentId);
}

handleButtonClick(payload: string) {
// Send as event or message depending on payload type
if (payload.startsWith('VIEW_') || payload.startsWith('SCHEDULE_')) {
this.chatService.sendMessage(payload, this.currentAgent);
}
}

handleQuickReply(payload: string) {
this.chatService.sendMessage(payload, this.currentAgent);
}

handleFileUpload(file: File) {
// Upload file first, then send event
this.uploadFile(file).then(fileUrl => {
this.chatService.sendEvent('FILE_UPLOADED', {
fileUrl,
fileName: file.name,
fileType: file.type
}, this.currentAgent);
});
}

private async uploadFile(file: File): Promise<string> {
// Implement file upload to your server
const formData = new FormData();
formData.append('file', file);

const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});

const data = await response.json();
return data.url;
}

private scrollToBottom() {
setTimeout(() => {
const container = document.querySelector('.messages-container');
if (container) {
container.scrollTop = container.scrollHeight;
}
}, 100);
}
}

Template Example

chat.component.html:

<div class="chat-container">
<!-- Agent Selector -->
<div class="agent-selector">
<button
*ngFor="let agent of agents"
[class.active]="agent.id === currentAgent"
(click)="selectAgent(agent.id)">
{{ agent.icon }} {{ agent.name }}
</button>
</div>

<!-- Connection Status -->
<div class="connection-status" [class.connected]="isConnected">
{{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }}
</div>

<!-- Messages -->
<div class="messages-container">
<div *ngFor="let message of messages"
[class.user-message]="message.sender === 'user'"
[class.bot-message]="message.sender === 'bot'">

<!-- Text Message -->
<div *ngIf="message.type === 'text'" class="message-text">
{{ message.text }}
</div>

<!-- Buttons -->
<div *ngIf="message.buttons" class="message-buttons">
<button
*ngFor="let btn of message.buttons"
(click)="handleButtonClick(btn.payload || btn.url)">
{{ btn.title }}
</button>
</div>

<!-- Quick Replies -->
<div *ngIf="message.quick_replies" class="quick-replies">
<button
*ngFor="let qr of message.quick_replies"
(click)="handleQuickReply(qr.payload)">
{{ qr.title }}
</button>
</div>

<!-- Custom Payload (Modal) -->
<div *ngIf="message.custom_payload?.type === 'OPEN_MODAL'">
<!-- Trigger modal based on modal_id -->
</div>
</div>

<!-- Typing Indicator -->
<div *ngIf="isTyping" class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>

<!-- Input -->
<div class="input-container">
<input
[(ngModel)]="inputText"
(keyup.enter)="sendMessage()"
placeholder="Type a message..."
[disabled]="!isConnected">
<button (click)="sendMessage()" [disabled]="!isConnected">
Send
</button>
</div>
</div>

Connection Flow

1. User loads chat page

2. ChatComponent.ngOnInit()

3. ChatService.connect('bgv')

4. io() creates Socket.io connection

5. socket.on('connect') fires

6. socket.emit('join_channel', { channel_id: 'prophunt_bgv', user_id })

7. ChatGateway receives join_channel event

8. SocketAdapter.joinChannel(socketId, 'prophunt_bgv')

9. User now subscribed to agent's room

10. Ready to send/receive messages!

Handling Custom Payloads

File Upload Modal

// In component
openFileUploadModal(payload: any) {
const modalRef = this.modalService.open(FileUploadModalComponent);
modalRef.componentInstance.taskId = payload.data.taskId;

modalRef.result.then((file: File) => {
this.handleFileUpload(file);
});
}

Payment Modal

openPaymentModal(payload: any) {
const modalRef = this.modalService.open(PaymentModalComponent);
modalRef.componentInstance.amount = payload.data.amount;
modalRef.componentInstance.paymentUrl = payload.data.paymentUrl;

// Open payment URL in new tab
window.open(payload.data.paymentUrl, '_blank');
}

Error Handling

this.socket.on('connect_error', (error) => {
console.error('Connection error:', error);

if (error.message.includes('timeout')) {
this.showErrorToast('Connection timeout. Please check your network.');
} else if (error.message.includes('unauthorized')) {
this.showErrorToast('Unauthorized. Please log in again.');
this.router.navigate(['/login']);
} else {
this.showErrorToast('Failed to connect to chat server.');
}
});

this.socket.on('error', (error) => {
console.error('Socket error:', error);
this.showErrorToast('Chat error occurred. Please refresh.');
});

Testing

Unit Test

describe('ChatService', () => {
let service: ChatService;
let mockSocket: any;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ChatService,
{ provide: AuthService, useValue: { getUserId: () => 'test123' } }
]
});

service = TestBed.inject(ChatService);
});

it('should connect to correct channel', () => {
service.connect('bgv');
// Verify socket.io connection
});

it('should send message correctly', () => {
service.connect('bgv');
service.sendMessage('Hello', 'bgv');
// Verify socket.emit called with correct params
});
});

Best Practices

Always disconnect on component destroy - Prevents memory leaks
Handle reconnection gracefully - Show UI feedback
Persist messages locally - Survive page refresh
Debounce typing indicator - Don't send on every keystroke
Validate socket connection - Before sending messages

Next Steps