GhostHub Chat & Commands: A Developer Deep Dive
By Ghosthub
A technical walkthrough of the GhostHub Pi Edition's in-app chat and slash-command system—how the pieces fit together, from WebSocket events to command modules.
The Big Picture
Note: This post covers the chat and command system in GhostHub Pi Edition. Not all commands shown here are available in every version—some are Pi Edition exclusives. Check the product page for the full feature breakdown.
The GhostHub chat and command system started as a simple overlay, but it's grown into the main way users interact with each other and navigate media. Here's a look at how it all fits together.
At a high level, everything runs through a single WebSocket connection shared across the app:
- Backend — Python with Flask-SocketIO handles events in
socket_events.py, with constants defined inconstants.py - Frontend — Vanilla JS modules:
chatManager.jsfor the UI,commandHandler.jsfor parsing, and individual command files undercommands/
The chat layer never owns the socket connection, it just registers listeners and emits events using a well-defined protocol.
The Event Protocol
All chat events flow through symbolic names defined in constants.py:
CHAT_MESSAGE— User messages broadcast to everyoneCHAT_NOTIFICATION— Join/leave notificationsCOMMAND— Structured command payloads (like/myviewbroadcasts)REQUEST_VIEW_INFO/VIEW_INFO_RESPONSE— Used by/viewto pull another user's stateUPDATE_MY_STATE— Clients reporting their current position
On the server, handlers validate and normalize everything before rebroadcasting. A typical message handler looks something like this:
@socketio.on('chat_message')
def handle_chat_message(data):
if not data or 'message' not in data:
return
user_id = (request.cookies.get('session_id', 'unknown') or '')[:8]
message_data = {
'user_id': user_id,
'message': data['message'].strip(),
'timestamp': data.get('timestamp')
}
emit('chat_message', message_data, room=CHAT_ROOM)
The server never trusts raw client input—it re-emits a structured payload with a truncated session ID for identity.
Chat Manager: The Frontend Orchestrator
chatManager.js owns the UI and socket bindings. Initialization (initChat(socketInstance)) does a few key things:
- Stores the shared Socket.IO instance
- Initializes the command handler with a callback for local system messages
- Creates the command popup controller
- Restores chat position and history from
sessionStorage - Registers DOM listeners (toggle, drag, form submit)
- Joins the chat room
Chat history persists in sessionStorage under keys like ghosthub_chat_messages and ghosthub_chat_position_x. Messages are rehydrated on load, and the chat container is draggable with bounds checking so it never ends up off-screen.
The module exposes a minimal public surface: expandChat(), collapseChat(), toggleChat(). Everything else stays internal.
Join, Rejoin, Leave
The system tracks whether you've already joined chat this session. On first load, it emits join_chat and broadcasts a notification. On refresh, it emits rejoin_chat instead—same reconnection, but no spam in the chat log.
This keeps things clean when someone's refreshing repeatedly or navigating between pages.
The Slash Command Pipeline
When you submit a message starting with /, it goes through the command handler instead of broadcasting:
if (commandHandler && message.startsWith('/')) {
const wasHandled = commandHandler.processCommand(message);
if (wasHandled) {
chatInput.value = '';
return;
}
}
If processCommand returns true, nothing goes over the wire. Unknown commands never leave the client—they just show a local error.
The handler itself does:
- Parsing — Splits
/name arg...and normalizes double-slashes (//→/) - Rate limiting — Tracks a rolling window of execution timestamps
- Execution — Calls
commands[commandName].execute(socket, displayLocalMessage, arg)
Arguments are passed through unparsed—each command decides how to validate them.
Command Discovery Popup
Type / in an empty input and a command palette appears. It reads from the same registry the handler uses:
const allCommands = Object.entries(window.appModules.commandHandler.commands);
const filteredCommands = allCommands.filter(([name]) =>
name.toLowerCase().startsWith(filterText.toLowerCase())
);
Arrow keys navigate, Enter selects. If the command expects arguments (detected by looking for {, [, < in the help text), it drops you into the input with /name ready to go. Otherwise it executes immediately.
Works with keyboard or touch—taps are distinguished from scroll gestures with a small distance/time threshold.
Core Commands
Here's a sampling of what's available under commands/:
/myview — Share Your Current View
Broadcasts a clickable deep-link to everyone in chat. The flow:
- Check
ensureFeatureAccess()(password gate) - Read
app.state.currentCategoryIdandapp.state.currentMediaIndex - Emit a
commandevent with{ cmd: 'myview', arg: { category_id, index }, from: socket.id } - Server enriches it with the sender's
media_orderand broadcasts toCHAT_ROOM
On the receiving end, chatManager renders it as a clickable "Jump to this view" link with data attributes. Clicking it calls mediaLoader.viewCategory(categoryId, mediaOrder, index).
/view [session] — Jump to Someone Else
Asks the server for another session's state:
socket.emit('request_view_info', { target_session_id: targetSessionId });
Server looks it up in SyncService and sends back category_id, index, and media_order. Client navigates there.
/find — Search Your Library
Hits a simple HTTP API (GET /api/search?q=<query>&limit=8) and renders results inline in chat. Clicking a result calls mediaLoader.viewCategory() with a forced order containing just that item.
/random — Smart Random Navigation
Prefers the current category when possible. Pass new to force a different category. Uses the categories API to find non-empty options, picks one, loads it, then jumps to a random index.
/add and /remove — Session Playlists
/add posts the current media to /api/session/playlist/add and triggers a category reload. /remove only works in the session-playlist category—removes the item, updates state in place, and advances or drops back to category view if empty.
/kick — Admin Moderation
Validates the target ID format, checks /api/admin/status to verify admin rights, then emits admin_kick_user. All enforcement happens server-side.
Security Layers
A few principles applied consistently:
- Gated features — Sensitive commands use
ensureFeatureAccess()before doing anything - Server-side validation — Handlers validate payloads before acting; non-admins are silently ignored for admin ops
- Rate limiting — Per-client, with a friendly "slow down" message
- Minimal identity — Chat uses truncated session IDs only
Adding New Commands
This is intentionally simple. Create a module:
// static/js/commands/example.js
export const example = {
description: '• One-line description here.',
getHelpText: () => '• /example [arg] Short help text.',
execute: async (socket, displayLocalMessage, arg) => {
// ...
}
};
Register it in commands/index.js:
import * as exampleCommand from './example.js';
export const commands = {
// ...existing...
example: exampleCommand.example
};
That's it. The popup and /help automatically pick it up—no additional wiring.
Wrapping Up
The chat and command system is designed to be integrated (everything shares one WebSocket), modular (commands are isolated files), discoverable (popup + /help), and safe (gating, validation, rate limiting).
If you're building on GhostHub, the main touchpoints are:
chatManager— Surface new message typescommandHandler+commands/index.js— Define or adjust commands- Individual command modules — Add focused behaviors
Hope this helps whether you're extending the open source version or running the Pi Edition. If you build something cool, I'd love to hear about it.
Ghost on. 👻