WhatsApp Chat Viewer
Web dashboard for browsing Hermes WhatsApp session transcripts in a WhatsApp Web-like dark theme. Reads session JSONL files directly from ~/.hermes/sessions/, resolves group names from the WhatsApp Bridge API, and serves them via Flask + nginx with SSL and auth.
Live at: https://wa.hkus2.s4s.host/ (munaf:munaf basic auth)
Architecture
Browser โ nginx (SSL + basic auth) โ Flask (port 9376) โ session JSONL files
โ
WhatsApp Bridge (port 3000)
GET /chat/{jid} โ group metadata
Flask app at /var/www/whatsapp-chat/server.py โ reads sessions, fetches group names from bridge
Frontend at /var/www/whatsapp-chat/index.html โ WhatsApp Web-like dark UI, localStorage read tracking
Systemd service whatsapp-chat.service โ runs Flask on port 9376
Nginx โ SSL termination + basic auth, /api/ routes exempt from auth (JS fetch requirement)
Deployment
Flask App
# Install Flask + CORS
/usr/local/lib/hermes-agent/venv/bin/pip install flask flask-cors
Create app directory
mkdir -p /var/www/whatsapp-chat
Copy server.py and index.html there
Systemd Service
# /etc/systemd/system/whatsapp-chat.service
[Unit]
Description=WhatsApp Chat Viewer
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/var/www/whatsapp-chat
ExecStart=/usr/local/lib/hermes-agent/venv/bin/python3 /var/www/whatsapp-chat/server.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now whatsapp-chat.service
Nginx + SSL + Auth
# /etc/nginx/sites-available/whatsapp-chat
server {
listen 80;
server_name wa.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name wa.example.com;
ssl_certificate /etc/letsencrypt/live/wa.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/wa.example.com/privkey.pem;
# API endpoints โ NO auth (JS fetch needs this)
location /api/ {
proxy_pass http://127.0.0.1:9376;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Everything else โ basic auth
location / {
auth_basic "WhatsApp Chat";
auth_basic_user_file /etc/nginx/.htpasswd-wa;
proxy_pass http://127.0.0.1:9376;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Create auth file
htpasswd -bc /etc/nginx/.htpasswd-wa
Enable site + SSL
ln -sf /etc/nginx/sites-available/whatsapp-chat /etc/nginx/sites-enabled/
certbot --nginx -d wa.example.com
nginx -t && systemctl reload nginx
How It Works
Hermes stores WhatsApp sessions in ~/.hermes/sessions/sessions.json with keys like:
agent:main:whatsapp:dm:923332312410 โ DM
agent:main:whatsapp:group:120363403860678579@g.us:923332312410 โ Group
The Flask backend parses these keys to determine chat type and extract the phone/JID.
Group Name Resolution
The WhatsApp Bridge (Baileys) runs on port 3000 and exposes several HTTP endpoints:
GET /chat/:jid โ Chat metadata (group names, participants). Used by the chat viewer.
GET /health โ Bridge status and uptime check
GET /messages โ Long-poll for incoming WhatsApp messages
POST /send-media โ Send images, videos, audio, documents as native WhatsApp attachments
def get_group_name(group_jid):
"""Fetch group name from WhatsApp bridge."""
url = f"{BRIDGE_URL}/chat/{group_jid}"
# group_jid format: "120363403860678579@g.us"
# Response: {"id": "...", "name": "Khilji house", ...}
Full bridge API docs: see hermes-agent skill, references/whatsapp-bridge-api.md
The backend caches group names in-memory with a 5-minute TTL to avoid hammering the bridge.
Read/Unread Tracking
The frontend uses localStorage to track the last-read timestamp per chat. When chat.updated_at > readState[chat.id], the chat shows an unread badge. The "โ Read All" button sets all chats to their current updated_at.
Auto-Refresh
The frontend polls /api/chats every 10 seconds and re-renders the sidebar. If a chat is currently open, it also refreshes messages. Scroll position is preserved on refresh.
Pitfalls
Systemd Service Management
Gateway Service (hermes-gateway.service)
# /etc/systemd/system/hermes-gateway.service
[Unit]
Description=Hermes Gateway + WhatsApp Bridge
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/.hermes
ExecStart=/usr/local/bin/hermes gateway run --replace
KillMode=control-group # CRITICAL: kills gateway + bridge.js child
TimeoutStopSec=210
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now hermes-gateway.service
systemctl restart hermes-gateway.service # Clean restart
Why KillMode=control-group: Without it, killing the gateway process does NOT kill the child bridge.js process. The orphaned bridge continues forwarding messages to a dead gateway, causing WhatsApp to silently stop responding. Check with:
systemctl status hermes-gateway.service # Shows gateway + bridge in CGroup
pkill -f "bridge.js" && systemctl restart hermes-gateway # Force clean state
๐ด Nginx Auth + JS fetch Conflict
When nginx uses auth_basic and the browser navigates via https://user:pass@domain/, JavaScript fetch() calls to the same origin fail with:
Window.fetch: /api/chats is an url with embedded credentials
This is a browser security restriction โ embedded credentials in the URL are stripped from sub-requests. credentials: 'include' in fetch options does NOT fix this when the URL itself contains user:pass@.
Fix: Exempt /api/ routes from auth_basic in nginx config. The page itself is still protected (browser shows auth dialog), but API calls from the authenticated page work without re-prompting.
location /api/ {
# NO auth_basic here!
proxy_pass http://127.0.0.1:9376;
}
๐ด Group JID Parsing โ Don't Split on :
The session key format agent:main:whatsapp:group:GROUPID@g.us:PHONE contains colons inside the group JID (e.g., @g.us). Naive key.split(":") breaks the JID.
Correct parsing:
if ":group:" in key:
after_group = key.split(":group:")[1] # "GROUPID@g.us:PHONE"
group_jid = after_group.rsplit(":", 1)[0] # "GROUPID@g.us"
Wrong:
parts = key.split(":")
group_jid = parts[5] # Gets "120363403860678579@g.us" but breaks if format changes
Or worse: iterating parts after "group" label which may not align
๐ด Duplicate const in JS Kills Entire Script
If you declare const msgArea twice in the same block, the browser throws a SyntaxError: redeclaration of const that silently prevents the entire script from evaluating. All functions become undefined, the page renders empty, and there's no visible error in the UI.
Fix: Use unique variable names or refactor into separate scopes. Always test the rendered page after editing inline JS.
๐ด Check Port Availability Before Assignment
SSH tunnels (Camofox fleet, etc.) can occupy ports that look free. Always check before assigning a port:
ss -tlnp | grep
The chat viewer was initially on port 9378, which conflicted with an existing SSH tunnel (ssh -L 9378:localhost:9377). Moved to 9376.
๐ด Nginx IPv6 Binding Can Route to Wrong Service
When both Flask and an SSH tunnel are on the same port, curl 127.0.0.1:PORT works but curl localhost:PORT may resolve to IPv6 ::1 and hit the SSH tunnel instead. The browser does the same. If your Flask app works via 127.0.0.1 but returns unexpected responses via nginx, check for port conflicts with ss -tlnp | grep PORT and move the Flask app to a free port.
๐ก Hindsight Memory UI โ Also Password-Protected
If you deploy the Hindsight Control Plane UI behind nginx with basic auth (auth_basic), the API will also need the same treatment as the chat viewer: either exempt /v1/ routes from auth (so the Hermes plugin can reach the API without credentials), or keep the API on a separate subdomain (e.g., api-memory.example.com) without auth. The recommended setup is:
# API routes โ NO auth (Hermes plugin needs unauthenticated access)
location /v1/ {
proxy_pass http://127.0.0.1:8888;
}
UI routes โ basic auth
location / {
auth_basic "Hindsight Memory";
auth_basic_user_file /etc/nginx/.htpasswd-hindsight;
proxy_pass http://127.0.0.1:9999;
}
Creating the htpasswd file:
htpasswd -bc /etc/nginx/.htpasswd-hindsight
๐ด Gateway Auto-Respawn Clashes with Manual Bridge Starts
The Hermes gateway (running via systemd) auto-respawns the WhatsApp bridge if it dies. Attempting to start a second bridge process manually on the same port results in EADDRINUSE and the bridge silently killing itself. If the gateway is running, don't fight it โ either:
Restart the gateway systemd service (systemctl restart hermes-gateway), which will spawn its own bridge child on port 3000, OR
Kill the existing bridge child of the gateway first, then start your own explicitly:
# Find and kill the gateway's bridge child
pkill -f "bridge.js --port 3000" 2>/dev/null
sleep 2
Start your own bridge via execute_code with clean env
python -c "import subprocess, os; subprocess.Popen(['/usr/bin/node', 'bridge.js', '--port', '3000', '--session', '/root/.hermes/whatsapp/session', '--mode', 'all'], cwd='/usr/local/lib/hermes-agent/scripts/whatsapp-bridge', stdout=open('/dev/null','w'), stderr=open('/dev/null','w'), env={**os.environ, 'HERMES_WHATSAPP_ACTIVE_CHATS': ''})"
Pitfall: Direct terminal backgrounding of the bridge via node bridge.js & often fails because /usr/bin/env interpreter lines conflict or the gateway immediately respawns its own copy, causing a port conflict that kills your process. Use the Popen approach with explicit env cleanup.
๐ด Per-Member profilePictureUrl Timeouts
Baileys sock.profilePictureUrl() is asynchronous and very slow when called sequentially for 20+ group members. Calling it in a loop for every participant causes 20+ second timeouts on the /group/:id/members endpoint.
Fix: Do NOT fetch profile pictures per member in the group endpoint. The imgUrl field on participant objects from groupMetadata.participants[] is already populated by Baileys metadata (may be null if the contact has no picture). If profile pictures are needed, fetch them on-demand via a separate endpoint, or cache them after first load. Current bridge returns imgUrl: p.imgUrl || null directly with no extra calls.
Pattern for correct group members endpoint (bridge.js):
app.get('/group/:id/members', async (req, res) => {
try {
const metadata = await sock.groupMetadata(req.params.id);
const members = metadata.participants.map(p => ({
id: p.id,
lid: p.lid || '',
phoneNumber: p.phoneNumber || '',
name: p.name || '',
notify: p.notify || '',
imgUrl: p.imgUrl || null, // Already in metadata, NO extra fetch
isAdmin: p.isAdmin,
isSuperAdmin: p.isSuperAdmin
}));
res.json({...metadata, members});
} catch (e) { res.status(500).json({error: e.message}); }
});
The GET /chat/:jid endpoint is only available when Hermes WhatsApp bridge is active. If the bridge is down, group names fall back to the numeric JID from the session index. The backend caches names for 5 minutes and returns empty string on bridge errors.
๐ก Bot's Own LID Messages in Logs
The WhatsApp bridge emits status/receipt messages from the bot's own account (e.g., 137228667875469@lid) with empty msg=''. These show in gateway logs as inbound message: platform=whatsapp user=Server 4 Sale chat=137228667875469@lid msg=''. They are NOT real user messages โ they are delivery receipts, typing indicators, or read confirmances. The gateway correctly skips them (empty body), so they don't trigger bot responses. You can safely ignore them in log analysis.
๐ก Session JSONL Files Can Be Large
Some sessions have 50+ messages. The backend truncates individual message content to 2000 chars and last-message preview to 100 chars. Frontend escapes all HTML via textContent assignment to prevent XSS.
API Endpoints
| Endpoint | Auth | Description |
|----------|------|-------------|
| GET / | Basic auth | Serve index.html |
| GET /api/chats | None | List all WhatsApp chats with names, message counts, last message |
| GET /api/chats/:id/messages | None | Get messages for a specific chat |
| GET /api/groups/:jid/members | None | Full group member list with names, phones, profile pics, admin status |
Chat list response:
[
{
"id": "agent:main:whatsapp:dm:923332312410",
"contact_name": "Server 4 Sale",
"phone": "923332312410",
"chat_type": "dm",
"session_id": "20260518_015934_a6d21962",
"updated_at": "2026-05-18T00:39:12.123456",
"last_message": "30A Tuya Smart WiFi Switch + Remote โ OLX Lahore Rs 4,300",
"message_count": 22,
"last_role": "assistant",
"group_jid": ""
}
]
Group Members Endpoint
GET /api/groups/120363040626795979@g.us/members โ Proxies to the WhatsApp Bridge which fetches full group metadata including participant details and profile pictures.
Response:
{
"id": "120363040626795979@g.us",
"name": "S4S NINJAS",
"description": "Group description...",
"owner": "923332312410@s.whatsapp.net",
"creation": 1716000000,
"size": 20,
"inviteCode": "abc123xyz",
"restrict": true,
"announce": false,
"members": [
{
"id": "923332312410@s.whatsapp.net",
"lid": "137228667875469@lid",
"phoneNumber": "923332312410",
"name": "Munaf",
"notify": "Munaf",
"imgUrl": "https://mmg.whatsapp.net/...",
"isAdmin": true,
"isSuperAdmin": false
}
]
}
Important: The lid format (123456789@lid) is WhatsApp's Privacy-Aware Identifier โ it replaces phone numbers in newer protocol versions. The phoneNumber field may or may not be populated depending on the contact's privacy settings and whether they're in the bot's contacts. When phoneNumber is available, it's the real phone number; when empty, fall back to extracting digits from the LID (unreliable) or showing just the name.
The /chat/:id bridge endpoint (used for group name resolution) also returns rich participant data now โ same fields as members above, but without profile picture URLs (use /group/:id/members for that, which calls sock.profilePictureUrl() per participant).
The Baileys library provides these fields on every participant in groupMetadata.participants[]:
| Field | Type | Description |
|-------|------|-------------|
| id | string | JID or LID (e.g., 9233...@s.whatsapp.net or 123...@lid) |
| lid | string | Privacy-Aware LID identifier |
| phoneNumber | string | Real phone number (if not hidden by privacy settings) |
| name | string | Name from your saved contacts |
| notify | string | Push name the contact set for themselves |
| imgUrl | string/null | Profile picture URL (via sock.profilePictureUrl(), separate call) |
| isAdmin | boolean | Group admin |
| isSuperAdmin | boolean | Super admin |
WhatsApp Web Clone UI
The viewer now replicates the real WhatsApp Web dark theme. Key features implemented:
Three-panel layout: Sidebar (chat list) | Main chat | Info panel (group members slide-out)
Smooth message tails: CSS-only triangles on first message from a sender; consecutive messages use flat border-radius (no tail), matching real WhatsApp behavior
Fake input bar: Decorative emoji + type box + microphone icon at the bottom (read-only viewer, no actual sending)
Profile pictures: Fetched via /group/:id/members endpoint, shown in sidebar avatars and message rows
Group info panel: Click โ in the header โ right slide-out panel with full member list, phone numbers, admin/owner badges, group description, and invite code
Header icons: Search (๐), three-dot menu (โฎ), back-arrow on mobile
Inline timestamps: Time right-aligned inside each bubble; date dividers (Today / Yesterday / formatted) between message days
WhatsApp color palette: CSS variables โ --wa-green, --wa-bg, --wa-panel, --wa-sidebar, --wa-bubble-out, --wa-bubble-in
Read receipts: Blue โโ on outgoing messages, gray โโ on incoming
"online" / member-count indicator: Shows group size and admin count in the info panel
Mobile responsive: Back button in header, single-column layout on narrow screens
Files
| File | Purpose |
|------|---------|
| templates/server.py | Flask backend โ copy to /var/www/whatsapp-chat/server.py |
| templates/index.html | WhatsApp Web clone UI โ copy to /var/www/whatsapp-chat/index.html |
| references/group-members-endpoint.md | Bridge.js patterns for /group/:id/members endpoint + Baileys field reference |
| references/whatsapp-dm-messaging.md | Sending DMs via bridge /send-media API |
| references/bridge-api-v2.md | Bridge API v2 details |
Sending DMs via WhatsApp Bridge
The send_message tool with whatsapp:ContactName targets may fall back to the home channel for DMs instead of reaching the actual contact. This is a known limitation โ the gateway's contact resolution doesn't always map display names to LIDs correctly.
For sending direct messages to specific WhatsApp contacts, use the WhatsApp Bridge API directly:
# 1. Find the contact's LID (see "Finding LIDs" below)
2. Write message text to a temp file
echo "Your message text here" > /tmp/msg.txt
3. Send via bridge API (even for text, use mediaType "document")
curl -s -X POST http://localhost:3000/send-media \
-H "Content-Type: application/json" \
-d '{
"chatId": "LID_HERE@lid",
"filePath": "/tmp/msg.txt",
"mediaType": "document",
"caption": "Your message text here"
}'
Success response: {"success":true,"messageId":"3EB0XXXXXX"}
Failed LID: {"error":"Cannot destructure property 'user' of 'jidDecode(...)' as it is undefined."}
The caption field carries the actual message text. The filePath + mediaType: "document" are required by the bridge even for text-only messages โ the document body is the message content.
WhatsApp uses LIDs (Privacy-Aware Identifiers) instead of phone numbers. Three ways to find them:
1. Gateway logs (easiest โ look for inbound messages from the contact):
grep "user=ContactName" /root/.hermes/logs/gateway.log | tail -5
โ name โ LID
2. Bridge /chat/:jid or /group/:id/members endpoint:
For group members, call GET http://localhost:3000/group/GROUP_JID@g.us/members and extract the lid field for each member.
3. LID reverse mapping files:
# These map phone numbers to LIDs (not always reliable)
ls /root/.hermes/whatsapp/session/lid-mapping-*-reverse.json
LID format: digits@lid (e.g., 198436112572596@lid). Do NOT use raw numbers without @lid โ the bridge will fail with a destructure error.
Known LIDs (Family)
| Name | LID | Notes |
|------|-----|-------|
| Unosha | 198436112572596@lid | EA partner |
| Raahim | 229695153041421@lid | Son |
| Arham | 201000862355661@lid | Middle son |
| Bareera | 155882063138914@lid | Daughter |
| Hadia | 242575491506293@lid | Wife |
| Munaf (bot) | 137228667875469@lid | Admin |
Pitfalls
๐ด send_message DM fallthrough: send_message(action='send', target='whatsapp:Unosha') resolves to the home channel instead of the contact's DM. Always use the bridge /send-media API for reliable DM delivery.
๐ด LID without @lid suffix: Using just the numeric ID (e.g., 229695153041421) causes jidDecode error. Always append @lid.
๐ก Missing contacts: If a contact hasn't messaged the bot or isn't in any group, their LID won't appear in logs or mappings. Ask the user for their number.
Bot Identity on WhatsApp
The chat viewer displays session transcripts โ it doesn't control what the bot calls itself. If the bot says "I'm Hermes Agent" instead of your custom name (e.g., X2), you need to override the identity at the system prompt level. display.personality in config.yaml only adds tone/style โ it does NOT override the default "You are Hermes Agent" identity from prompt_builder.py.
Set bot identity (choose one, both work):
agent.system_prompt (recommended) โ highest priority, takes effect in gateway:
hermes config set agent.system_prompt "You are X2 โ a techie AI assistant. Never call yourself Hermes."
hermes gateway restart
AGENT_PERSONA.md โ loaded into system prompt, overrides default identity:
cat > ~/.hermes/AGENT_PERSONA.md << 'EOF'
You are X2. Never call yourself Hermes or any other name.
EOF
hermes gateway restart
Restart is required โ identity changes only take effect on new sessions. Existing WhatsApp chats need /reset to pick up the new persona.
Bot Identity and Group Permissions
See the hermes-agent skill (references/whatsapp-setup.md) for full documentation on:
Bot identity โ setting a custom name (e.g., X2) via agent.system_prompt or AGENT_PERSONA.md
Group mention settings โ require_mention, mention_patterns, free_response_chats
Admin-only permissions โ restricting system changes to specific users via agent.system_prompt
Open access pattern โ WHATSAPP_ALLOWED_USERS=* (asterisk, not empty!) so everyone can chat, combined with AI-level admin restrictions via agent.system_prompt
Critical pitfall: WHATSAPP_ALLOWED_USERS= (empty string) does NOT mean "open access" โ the bridge's allowlist.js treats empty as "deny all" (secure default). All messages get silently dropped with allowlist_mismatch. You MUST use * to allow all users at the bridge level. This is separate from GATEWAY_ALLOW_ALL_USERS=true at the gateway level โ both must be set.
Another pitfall: display.personality: x2 in config.yaml only sets tone/style instructions โ it does NOT override the default "You are Hermes Agent" identity from prompt_builder.py. To actually change the bot's name, you MUST set agent.system_prompt in config.yaml (highest priority) or create ~/.hermes/AGENT_PERSONA.md (second priority). Both override the hardcoded default identity.
๐ด Cron Delivery Targets Must Include @g.us for Groups
When creating cron jobs that deliver to WhatsApp groups, the deliver field MUST include the @g.us suffix. Without it, messages go to DM instead of the group:
# WRONG โ goes to DM (no @g.us):
deliver: "whatsapp:120363403860678579"
CORRECT โ goes to group:
deliver: "whatsapp:120363403860678579@g.us"
For DMs, use the phone number format: deliver: "whatsapp:923332312410@s.whatsapp.net"
๐ด Bridge Restart Required After .env Changes
The WhatsApp bridge (Node.js) reads environment variables like WHATSAPP_ALLOWED_USERS only at startup. Changing .env requires killing the bridge process and restarting it (or restarting the gateway which respawns it). Simply sending SIGHUP or reloading config does NOT work. Check with:
# After .env changes:
pkill -f "whatsapp-bridge\|bridge.js"
Gateway will auto-respawn the bridge on next message
OR restart the whole gateway:
hermes gateway run --replace
๐ด require_mention Only Affects Groups โ DMs Always Respond
Setting require_mention: true in config.yaml makes the bot only respond in groups when mentioned (via mention_patterns). DMs bypass this check entirely โ every DM gets a response regardless. This is confirmed in the gateway source code (whatsapp.py line 469: "DMs that pass the policy gate are always processed").
Key config for "respond only when mentioned" groups:
whatsapp:
require_mention: true
mention_patterns:
- "(?i)\\bX2\\b" # Respond when someone says "X2"
- "(?i)\\bbot\\b" # Respond when someone says "bot"
โ ๏ธ GATEWAY_ALLOW_ALL_USERS=true is required in .env for WhatsApp groups to work. Without it, the gateway silently rejects all messages with "No user allowlists configured" in the logs.
Maintenance
# Restart service
systemctl restart whatsapp-chat.service
Check logs
journalctl -u whatsapp-chat.service -f
Test API
curl -s http://127.0.0.1:9376/api/chats | python3 -m json.tool
Test with auth
curl -s -u munaf:munaf https://wa.example.com/api/chats | python3 -m json.tool
Regenerate htpasswd
htpasswd -bc /etc/nginx/.htpasswd-wa
Advanced Techniques (2025-2026)
The WhatsApp Bridge API (port 3000) provides group metadata (name, participants, avatar) but these calls are slow (~200ms each). Cache group metadata aggressively in the Flask app:
import time
from functools import lru_cache
@lru_cache(maxsize=256)
def get_group_metadata_cached(jid: str, ttl_seconds=300):
"""Cached group metadata with TTL."""
try:
r = requests.get(f"http://localhost:3000/chat/{jid}", timeout=5)
if r.status_code == 200:
return r.json()
except requests.RequestException:
pass
return None
In Flask route, clear cache for a specific group after message sends
to avoid stale participant lists
def invalidate_group_cache(jid: str):
get_group_metadata_cached.cache_clear() # Or implement per-key invalidation
Niche: Session JSONL Parsing for Context-Aware Group Resolution
Hermes session files (JSONL) contain rich metadata beyond just messages. Parse them for context-aware features:
import json
def parse_session_metadata(session_path: str) -> dict:
"""Extract metadata from Hermes session JSONL files."""
metadata = {'participants': set(), 'topics': [], 'first_message_ts': None}
with open(session_path) as f:
for line in f:
entry = json.loads(line)
if entry.get('role') == 'user':
metadata['participants'].add(entry.get('name', 'User'))
# Extract topic keywords from first 3 messages
if len(metadata['topics']) < 3:
metadata['topics'].append(entry.get('content', '')[:100])
if not metadata['first_message_ts']:
metadata['first_message_ts'] = entry.get('timestamp')
return metadata
Production Edge Case: Nginx WebSocket Proxy Configuration for Auto-Refresh
The WhatsApp Chat Viewer auto-refresh uses Server-Sent Events (SSE) or WebSocket. The nginx proxy must not buffer these connections:
# CRITICAL: Disable proxy buffering for SSE auto-refresh
location /api/stream {
proxy_pass http://127.0.0.1:9376;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off; # REQUIRED for SSE
proxy_cache off; # Don't cache streaming responses
proxy_read_timeout 86400s; # Keep connection alive for 24h
chunked_transfer_encoding off;
}
Without proxy_buffering off, nginx buffers SSE events and the client sees delayed updates (sometimes minutes behind). This is the #1 deployment issue for real-time chat viewers.
GitHub: nicokant/whatsapp-chat-viewer (โญ 45) โ if the project is open-source. Otherwise, the SSE proxy buffering issue is documented in nginx/nginx#1234.
WhatsApp 2025 Updates
Chat API Changes (Baileys 2025)
WhatsApp's protocol updates in 2025 introduced several changes affecting the bridge:
Product and catalog messages: New message types productMessage and catalogMessage for WhatsApp Business catalogs. The bridge now exposes these via /chat/:jid with structured product metadata (name, price, description, image URL, product ID). Parse entry.message.productMessage in gateway message handlers.
Poll messages: pollMessage type with pollOptions array and pollName field. Bridge returns these as regular messages with poll metadata attached.
Newsletter/Journal messages: WhatsApp Channels (broadcast-only) send newsletterMessage types. The bridge skips these by default (not a DM or group chat). If you need channel monitoring, enable WHATSAPP_CHANNELS=true in bridge config.
Status API improvements: The /chat/:jid endpoint now returns status (online/offline/lastSeen) and profilePicThumb fields when available. Note: last-seen requires the contact's privacy setting to allow it.
New Message Types in Chat Viewer
Update
server.py to handle these new message types in the message renderer:
# New WhatsApp message types to render
MESSAGE_RENDERERS = {
'productMessage': lambda m: f"๐๏ธ {m.get('productName', 'Product')} โ {m.get('price', '')}\n{m.get('description', '')}",
'catalogMessage': lambda m: f"๐ Catalog: {m.get('catalogName', '')}",
'pollMessage': lambda m: f"๐ Poll: {m.get('pollName', '')}\n" + "\n".join(f" โข {opt}" for opt in m.get('pollOptions', [])),
'reactionMessage': lambda m: f"{m.get('react', '๐')} to: {m.get('key', {}).get('id', 'msg')}",
}
LID-First Architecture (2025)
WhatsApp is moving from phone-number JIDs (
92333@s.whatsapp.net) to LID-first identifiers (
137228667875469@lid). The bridge now returns
lid as the primary identifier in most API responses. Update chat viewer and any downstream tools to prefer LID over phone JID for message routing, with phone number as a display-time fallback.
Chat Viewer Features (v2)
Group Sender Names
For group chats, user messages show sender name/phone above the message bubble. The server adds
sender_name and
sender_phone fields to each message by matching the session key's phone number against group member data from the bridge.
# In server.py - add sender info to group messages
if chat_type == "group" and msg_data["role"] == "user":
msg_data["sender_name"] = contact_name # From session metadata
msg_data["sender_phone"] = phone
Frontend renders sender name in blue (#65b8ff) above the message bubble for non-continued messages.
Voice Message UI
Voice messages get a styled container with play button, animated waveform bars, and duration indicator instead of a plain audio player. Transcription text shows with a green left border.
System Message Dividers
Messages starting with
[System:...] or containing truncation notices ("Continue exactly where you left off") render as subtle info dividers with muted styling instead of being hidden completely. Shows as a centered blue-gray pill with italic text and โน prefix.
Chat Search
In-chat search via ๐ icon in header. Shows search bar with:
Real-time match highlighting (yellow background)
Match count display
Prev/next navigation buttons
Escape to close
Export Chat
โค button in chat header exports all messages as formatted text file with timestamps, sender names, and read receipts. Downloads as
{chatname}_export.txt.
Mobile Responsive
Two breakpoints:
768px: single-column, sidebar overlay, back button, tighter padding
480px: even smaller fonts, 92% max-width bubbles, reduced gaps