webex-message-handler
v0.6.9
Published
Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK
Maintainers
Readme
webex-message-handler
Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages — no Webex SDK required.
Why?
- The Webex JS SDK has unpatched vulnerabilities and ~300+ transitive dependencies
- Bots behind corporate firewalls need Hookbuster or public webhook endpoints
- This package extracts only the essential Mercury + KMS logic (~6 dependencies)
Install
npm install webex-message-handlerQuick Start
import { WebexMessageHandler, consoleLogger } from 'webex-message-handler';
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
logger: consoleLogger,
});
handler.on('message:created', (msg) => {
console.log(`[${msg.personEmail}] ${msg.text}`);
if (msg.html) {
console.log(` HTML: ${msg.html}`);
}
});
handler.on('message:deleted', (data) => {
console.log(`Message ${data.messageId} deleted by ${data.personId}`);
});
handler.on('message:updated', (msg) => {
console.log(`[EDIT] [${msg.personEmail}] ${msg.text}`);
});
handler.on('attachmentAction:created', (action) => {
console.log(`Card submitted by ${action.personEmail}:`, action.inputs);
});
handler.on('room:updated', (room) => {
console.log(`Room ${room.roomId} updated by ${room.actorId}`);
});
handler.on('connected', () => console.log('Connected to Webex'));
handler.on('disconnected', (reason) => console.log(`Disconnected: ${reason}`));
handler.on('reconnecting', (attempt) => console.log(`Reconnecting (attempt ${attempt})...`));
handler.on('error', (err) => console.error('Error:', err.message));
await handler.connect();
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('Shutting down...');
await handler.disconnect();
process.exit(0);
});See examples/basic-bot.ts for a complete working example.
Self-Message Filtering
By default, the library automatically filters out messages sent by your bot, preventing infinite response loops. On connect(), it fetches the bot's person ID via /people/me, normalizes it to a raw UUID, and silently drops any messages where the sender matches.
If /people/me fails (e.g., invalid token, network error), connect() will throw rather than silently running without protection. This fail-closed behavior ensures your bot never runs without self-message filtering active.
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
logger: consoleLogger,
// ignoreSelfMessages defaults to true — no config needed
});
handler.on('message:created', (msg) => {
// This will NEVER fire for the bot's own messages
console.log(`User said: ${msg.text}`);
});To receive the bot's own messages (e.g., for auditing), explicitly disable filtering. Only do this if you have your own loop prevention in place:
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
ignoreSelfMessages: false, // WARNING: risk of message loops
});Important: Implementing Loop Detection
This library only handles the receive side of messaging — it decrypts incoming messages from the Mercury WebSocket. It has no visibility into messages your bot sends via the REST API. This means it cannot detect message loops on its own.
If your bot replies to incoming messages, you must implement loop detection in your wrapper code. Without it, a bug or misconfiguration could cause your bot to endlessly reply to its own messages. Webex enforces a server-side rate limit (approximately 11 consecutive messages before throttling), but that still results in spam before the cutoff.
Recommended approach: Track your bot's outgoing message rate. If it exceeds a threshold (e.g., 5 messages in 3 seconds to the same room), pause sending and log a warning.
The ignoreSelfMessages option (default: true) provides a first line of defense by filtering out messages sent by this bot's own identity. If the library cannot verify the bot's identity during connect() (e.g., /people/me API failure), connection will fail rather than silently running without protection. Set ignoreSelfMessages: false to opt out, but only if you have your own loop prevention in place.
Proxy Support (Enterprise)
In native mode, a single undici ProxyAgent proxies both HTTP (fetch()) and the native WebSocket:
import { WebexMessageHandler } from 'webex-message-handler';
import { ProxyAgent } from 'undici';
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
dispatcher: new ProxyAgent(process.env.HTTPS_PROXY!),
});
await handler.connect();You can also read the proxy URL from standard environment variables:
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
dispatcher: proxyUrl ? new ProxyAgent(proxyUrl) : undefined,
});Injected Mode (Full Control)
For advanced scenarios where you need complete control over HTTP and WebSocket networking:
import { WebexMessageHandler } from 'webex-message-handler';
import { ProxyAgent } from 'undici';
const proxy = new ProxyAgent(process.env.HTTPS_PROXY!);
const handler = new WebexMessageHandler({
token: process.env.WEBEX_BOT_TOKEN!,
mode: 'injected',
fetch: async (request) => {
const response = await fetch(request.url, {
method: request.method,
headers: request.headers,
body: request.body,
dispatcher: proxy,
});
return {
status: response.status,
ok: response.ok,
json: () => response.json(),
text: () => response.text(),
};
},
webSocketFactory: (url) => {
// Return any object implementing InjectedWebSocket
// (send, close, readyState, on)
return createYourCustomWebSocket(url);
},
});Delivery Guarantees
This library provides at-most-once delivery semantics:
- Mercury WebSocket acknowledges messages at the protocol level on receipt, before decryption or consumer delivery.
- If decryption fails (e.g., KMS outage) or your callback throws an error, the message is not redelivered.
- Mercury does not support application-level ACK/NACK — this is an inherent constraint of the Webex platform.
For consumers requiring stronger guarantees:
- Wrap your callback with a persistent queue (e.g., database, Redis, or message broker) to ensure processing completes.
- Use the
errorevent to detect and log decryption failures. - The KMS circuit breaker (v0.6.9+) prevents 30-second stalls during KMS outages by failing fast after 3 consecutive failures.
API Reference
WebexMessageHandler
Main class for receiving and decrypting Webex messages.
Constructor
new WebexMessageHandler(config: WebexMessageHandlerConfig)Configuration options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| token | string | required | Webex bot access token |
| logger | Logger | noop | Custom logger (consoleLogger provided) |
| ignoreSelfMessages | boolean | true | Filter out messages sent by this bot |
| dispatcher | object (undici Dispatcher) | undefined | Proxy dispatcher for native mode (e.g., ProxyAgent) |
| pingInterval | number | 15000 | Mercury ping interval (ms) |
| pongTimeout | number | 14000 | Pong response timeout (ms) |
| reconnectBackoffMax | number | 32000 | Max reconnect backoff (ms) |
| maxReconnectAttempts | number | 10 | Max reconnect attempts |
| metricsCallback | MetricsCallback | undefined | Optional callback for timing metrics (connect, decrypt events) |
Methods
connect(): Promise<void>— Connects to Webex (registers device, initializes KMS, opens Mercury WebSocket)disconnect(): Promise<void>— Gracefully disconnects (closes WebSocket, unregisters device)reconnect(newToken): Promise<void>— Update token and re-establish connectionstatus(): HandlerStatus— Returns structured health check of all subsystems
Properties
connected: boolean— Whether currently connected to Mercury
Events
| Event | Payload | Description |
|-------|---------|-------------|
| message:created | DecryptedMessage | New message received and decrypted |
| message:deleted | { messageId, roomId, personId } | Message was deleted |
| message:updated | DecryptedMessage | Message was edited and re-decrypted |
| attachmentAction:created | AttachmentAction | Adaptive Card submitted |
| room:created | RoomActivity | New room/space created |
| room:updated | RoomActivity | Room/space updated |
| membership:created | MembershipActivity | Member added/removed or moderator changed |
| connected | — | Connected/reconnected to Mercury |
| disconnected | reason: string | Disconnected from Mercury |
| reconnecting | attempt: number | Attempting to reconnect |
| error | Error | Error occurred |
DecryptedMessage
Shape of decrypted messages:
{
id: string; // Mercury activity UUID
parentId?: string; // Parent activity UUID (threaded replies only)
roomId: string;
personId: string;
personEmail: string;
text: string;
html?: string;
created: string;
roomType?: string;
mentionedPeople: string[]; // Person UUIDs from <spark-mention> tags
mentionedGroups: string[]; // e.g. ["all"] from group mentions
files: string[]; // File attachment URLs
raw: MercuryActivity;
}AttachmentAction
Emitted when a user submits an Adaptive Card.
{
id: string; // Activity UUID
messageId: string; // Parent message containing the card
personId: string; // Person who submitted
personEmail: string;
roomId: string;
inputs: Record<string, unknown>; // Card form data
created: string;
raw: MercuryActivity;
}RoomActivity
Emitted for room lifecycle events.
{
id: string; // Activity UUID
roomId: string;
actorId: string; // Person who triggered the event
action: string; // "create" or "update"
created: string;
raw: MercuryActivity;
}parseMentions(html)
Extracts mentions from decrypted HTML. Called automatically during decryption — the results populate DecryptedMessage.mentionedPeople and DecryptedMessage.mentionedGroups. Exported for standalone use.
import { parseMentions } from 'webex-message-handler';
const { mentionedPeople, mentionedGroups } = parseMentions(msg.html);
// mentionedPeople: ["uuid-1", "uuid-2"]
// mentionedGroups: ["all"]Threading & Message IDs
Mercury uses raw activity UUIDs while the Webex REST API uses base64-encoded IDs. Use the conversion utilities to bridge them:
import { toRestId, fromRestId } from 'webex-message-handler';
handler.on('message:created', async (msg) => {
// Convert Mercury UUID to REST API ID for GET requests
const restId = toRestId(msg.id, 'MESSAGE');
// Reply in a thread (parentId works as-is with POST)
if (msg.parentId) {
await fetch('https://webexapis.com/v1/messages', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ roomId: msg.roomId, parentId: msg.parentId, text: 'Reply' }),
});
}
});Resource types: 'MESSAGE', 'PEOPLE', 'ROOM'.
Architecture
┌─────────────────────────────────────────────────┐
│ WebexMessageHandler │
│ (Main event emitter & lifecycle manager) │
└────────────────┬────────────────────────────────┘
│
┌────────────┼────────────┬────────────────┐
│ │ │ │
v v v v
┌──────────┐ ┌─────────┐ ┌──────────┐ ┌──────────────┐
│ Device │ │ Mercury │ │ KMS │ │ Message │
│ Manager │ │ Socket │ │ Client │ │ Decryptor │
│ │ │ │ │ │ │ │
│ • WDM │ │ • WS │ │ • ECDH │ │ • JWE │
│ • Auth │ │ • Ping/ │ │ • Key │ │ • AES-GCM │
│ • Reg │ │ Pong │ │ Fetch │ │ • Plaintext │
└──────────┘ └─────────┘ └──────────┘ └──────────────┘How It Works
The package follows a 5-step data flow for receiving and decrypting messages:
- Device Registration — Registers a device via the WDM API and obtains a device ID
- Mercury Connection — Opens a WebSocket connection to Mercury with token authentication and periodic heartbeat pings
- Encrypted Activity — Mercury sends encrypted activity objects when new messages arrive
- Key Retrieval — Fetches the decryption key from KMS via an ECDH-encrypted channel
- Decryption & Emission — Decrypts the message using JWE and emits a
message:createdevent
Advanced: Individual Components
For advanced use cases, individual components are also exported:
DeviceManager— Device registration and lifecycleMercurySocket— WebSocket connection and message receptionKmsClient— Key management service integrationMessageDecryptor— JWE decryption logic
Comparison
| Feature | webex-message-handler | Webex JS SDK | Hookbuster | |---------|----------------------|--------------|------------| | Dependencies | ~6 | ~300+ | Full SDK | | Vulnerabilities | 0 known | Multiple unpatched | Inherits SDK | | Message receive | Yes | Yes | Yes | | Message send | No (use REST API) | Yes | No | | Webhook required | No | No | No | | Binary size | ~50KB | ~5MB+ | ~5MB+ |
License
Apache-2.0
