Files
2026-07-13 12:20:06 +08:00

697 lines
26 KiB
Markdown

# RTMS Bot (Real-Time Media Streams)
Build resilient RTMS bots that access meeting audio, video, transcription, screen share, and chat without joining as a visible participant.
## Overview
RTMS bots are invisible read-only services that subscribe to meeting media streams via WebSockets. They do NOT appear in the participant list.
**Use this approach when:**
- You only need to observe/transcribe (no interaction needed)
- You want invisible operation
- You're processing external meetings (with permission)
- You want minimal resource usage
**Alternative:** See [Meeting SDK Bot (Linux)](../../meeting-sdk/linux/meeting-sdk-bot.md) for visible participant bots with full meeting control.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ RTMS BOT FLOW │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 1. Trigger RTMS: REST API or In-Meeting Start │
│ └── POST /meetings/{meetingId}/rtms │
│ └── Or: Start RTMS manually from Zoom client │
└────────────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 2. Wait for Webhook: meeting.rtms_started │
│ └── Zoom sends signaling + media WebSocket URLs │
│ └── No webhook = RTMS unavailable (no polling fallback) │
└────────────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 3. Connect: Signaling WebSocket (Handshake with HMAC) │
│ └── Generate HMAC-SHA256 signature │
│ └── Send handshake message │
│ └── Receive session confirmation │
└────────────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 4. Connect: Media WebSocket (Subscribe to Streams) │
│ └── Subscribe to: audio, video, transcription, share, chat │
│ └── Send keep-alive pings │
└────────────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 5. Process Media Data │
│ └── Audio: Opus/PCM streams per speaker │
│ └── Video: H.264 encoded frames │
│ └── Transcription: Real-time text with speaker labels │
└────────────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 6. Mid-Stream: Connection Monitoring │
│ └── Detect WebSocket close → Exponential backoff retry │
│ └── Stop after N reconnection attempts │
└─────────────────────────────────────────────────────────────────────┘
```
## Skills Required
| Skill | Purpose |
|-------|---------|
| **zoom-rest-api** | Trigger RTMS start (optional - can also start manually) |
| **rtms** | WebSocket connection, media processing |
| **webhooks** | Receive `meeting.rtms_started` event |
## Prerequisites
- Zoom app with RTMS features enabled
- Webhook endpoint (HTTPS, publicly accessible)
- Event subscriptions: `meeting.rtms_started`, `meeting.rtms_stopped`
- Scopes: `meeting:read:admin`, `meeting:write:admin` (if triggering via API)
- RTMS SDK or native WebSocket implementation
## Configuration
### Retry Parameters (Customizable)
```javascript
// config.js or environment variables
const rtmsConfig = {
// WebSocket connection (initial)
connection_timeout_ms: 10000, // Handshake timeout (default: 10s)
connection_max_attempts: 5, // Max connection attempts (default: 5)
connection_retry_delay_ms: 5000, // Constant retry: 5s (default: 5s)
// Mid-stream reconnection (network failures)
reconnect_max_attempts: 3, // Max reconnection attempts (default: 3)
reconnect_base_delay_ms: 2000, // Initial delay: 2s (default: 2s)
// Exponential backoff: 2s, 4s, 8s...
// Keep-alive ping
keepalive_interval_ms: 5000, // Send ping every 5s (default: 5s, min: 3s)
keepalive_timeout_ms: 15000, // Expect pong within 15s (default: 15s)
// Webhook wait timeout
webhook_wait_timeout_ms: 300000 // Wait 5min for webhook (default: 5min)
};
// Load from environment variables (recommended for production)
function loadConfig() {
return {
connection_timeout_ms:
parseInt(process.env.RTMS_CONNECTION_TIMEOUT_MS) || 10000,
connection_max_attempts:
parseInt(process.env.RTMS_CONNECTION_MAX_ATTEMPTS) || 5,
connection_retry_delay_ms:
parseInt(process.env.RTMS_CONNECTION_RETRY_DELAY_MS) || 5000,
reconnect_max_attempts:
parseInt(process.env.RTMS_RECONNECT_MAX_ATTEMPTS) || 3,
reconnect_base_delay_ms:
parseInt(process.env.RTMS_RECONNECT_BASE_DELAY_MS) || 2000,
keepalive_interval_ms:
Math.max(parseInt(process.env.RTMS_KEEPALIVE_INTERVAL_MS) || 5000, 3000),
keepalive_timeout_ms:
parseInt(process.env.RTMS_KEEPALIVE_TIMEOUT_MS) || 15000,
webhook_wait_timeout_ms:
parseInt(process.env.RTMS_WEBHOOK_WAIT_TIMEOUT_MS) || 300000
};
}
```
### Customization Guide
| Parameter | Default | When to Increase | When to Decrease |
|-----------|---------|------------------|------------------|
| `connection_max_attempts` | 5 | Slow/congested networks | Fast failure detection needed |
| `connection_retry_delay_ms` | 5000 (5s) | High network latency | Local network, low latency |
| `reconnect_max_attempts` | 3 | Critical meetings, unstable network | Cost-sensitive, batch processing |
| `reconnect_base_delay_ms` | 2000 (2s) | International connections | Local network |
| `keepalive_interval_ms` | 5000 (5s) | Aggressive connection monitoring | Reduce bandwidth overhead |
| `webhook_wait_timeout_ms` | 300000 (5min) | Meetings may start late | Fast failure detection |
**Recommended Ranges:**
- Connection attempts: 3-10
- Connection retry delay: 2s-15s
- Reconnect attempts: 2-5
- Reconnect base delay: 1s-5s
- Keep-alive interval: 3s-30s (min: 3s per Zoom docs)
**Examples:**
```bash
# High-priority production bot (aggressive)
export RTMS_CONNECTION_MAX_ATTEMPTS=10
export RTMS_CONNECTION_RETRY_DELAY_MS=3000 # 3s
export RTMS_RECONNECT_MAX_ATTEMPTS=5
export RTMS_RECONNECT_BASE_DELAY_MS=1000 # 1s
export RTMS_KEEPALIVE_INTERVAL_MS=3000 # 3s (minimum)
# Cost-sensitive batch processing (conservative)
export RTMS_CONNECTION_MAX_ATTEMPTS=3
export RTMS_CONNECTION_RETRY_DELAY_MS=10000 # 10s
export RTMS_RECONNECT_MAX_ATTEMPTS=2
export RTMS_RECONNECT_BASE_DELAY_MS=5000 # 5s
export RTMS_KEEPALIVE_INTERVAL_MS=15000 # 15s
# Development/testing (fail fast)
export RTMS_CONNECTION_MAX_ATTEMPTS=2
export RTMS_CONNECTION_RETRY_DELAY_MS=2000 # 2s
export RTMS_RECONNECT_MAX_ATTEMPTS=1
export RTMS_RECONNECT_BASE_DELAY_MS=1000 # 1s
export RTMS_WEBHOOK_WAIT_TIMEOUT_MS=60000 # 1min
```
## Step 1: Trigger RTMS (Optional - REST API)
You can start RTMS programmatically or manually from the Zoom client.
### Option A: REST API Trigger
```bash
# Start RTMS for a meeting
curl -X POST "https://api.zoom.us/v2/meetings/{meetingId}/rtms" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "meeting"
}'
```
**Response:**
```json
{
"rtms_id": "abc123def456",
"status": "starting"
}
```
### Option B: Manual Start (In-Meeting)
Host clicks **Apps** → Your RTMS App → **Start RTMS**
**Both trigger the same webhook**`meeting.rtms_started`
## Step 2: Wait for Webhook (Required)
**CRITICAL:** RTMS requires a webhook. There is NO polling alternative. If webhook doesn't arrive, RTMS is unavailable.
### Webhook Handler
```javascript
const express = require('express');
const crypto = require('crypto');
const app = express();
const config = loadConfig();
const pendingConnections = new Map(); // Track webhook waiters
app.post('/webhook', express.json(), async (req, res) => {
// 1. Verify webhook signature
const signature = req.headers['x-zm-signature'];
const timestamp = req.headers['x-zm-request-timestamp'];
if (!verifyWebhookSignature(req.body, signature, timestamp)) {
return res.status(403).send('Invalid signature');
}
// 2. Respond immediately (Zoom expects 200 within 3s)
res.status(200).send();
// 3. Process webhook asynchronously
const event = req.body;
if (event.event === 'meeting.rtms_started') {
console.log('[WEBHOOK] RTMS started for meeting:', event.payload.object.uuid);
const rtmsInfo = {
meetingUuid: event.payload.object.uuid,
signalingUrl: event.payload.object.signaling_url,
mediaUrl: event.payload.object.media_url,
sessionKey: event.payload.object.session_key
};
// Notify waiting connection
const waiter = pendingConnections.get(rtmsInfo.meetingUuid);
if (waiter) {
waiter.resolve(rtmsInfo);
pendingConnections.delete(rtmsInfo.meetingUuid);
} else {
// No waiter - proactive start
connectToRTMS(rtmsInfo);
}
}
});
function verifyWebhookSignature(body, signature, timestamp) {
const message = `v0:${timestamp}:${JSON.stringify(body)}`;
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET_TOKEN);
const computed = 'v0=' + hmac.update(message).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computed)
);
}
```
### Wait for Webhook (with Timeout)
```javascript
async function waitForRTMSWebhook(meetingUuid) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
pendingConnections.delete(meetingUuid);
reject(new Error(
`RTMS webhook not received within ${config.webhook_wait_timeout_ms}ms. ` +
`Possible causes: ` +
`(1) Meeting hasn't started, ` +
`(2) RTMS not enabled for this meeting, ` +
`(3) Webhook endpoint unreachable.`
));
}, config.webhook_wait_timeout_ms);
pendingConnections.set(meetingUuid, {
resolve: (rtmsInfo) => {
clearTimeout(timeoutId);
resolve(rtmsInfo);
},
reject
});
});
}
// Usage
try {
console.log('[RTMS] Waiting for webhook...');
const rtmsInfo = await waitForRTMSWebhook(MEETING_UUID);
console.log('[RTMS] Webhook received, connecting...');
await connectToRTMS(rtmsInfo);
} catch (error) {
console.error('[RTMS] ABORT:', error.message);
}
```
**Error if no webhook:** ABORT. No webhook = RTMS unavailable. No polling alternative.
## Step 3: Connect to Signaling WebSocket
### Connection with Retry
```javascript
const WebSocket = require('ws');
async function connectSignalingWithRetry(signalingUrl, sessionKey) {
for (let attempt = 1; attempt <= config.connection_max_attempts; attempt++) {
console.log(`[SIGNALING] Attempt ${attempt}/${config.connection_max_attempts}`);
try {
const ws = await connectSignalingSocket(signalingUrl, sessionKey);
console.log('[SIGNALING] Connected successfully');
return ws;
} catch (error) {
console.error(`[SIGNALING] Attempt ${attempt} failed:`, error.message);
if (attempt < config.connection_max_attempts) {
const delay = config.connection_retry_delay_ms;
console.log(`[SIGNALING] Retrying in ${delay}ms...`);
await sleep(delay);
}
}
}
throw new Error(
`Failed to connect signaling WebSocket after ${config.connection_max_attempts} attempts`
);
}
function connectSignalingSocket(signalingUrl, sessionKey) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(signalingUrl);
const timeoutId = setTimeout(() => {
ws.close();
reject(new Error('Signaling connection timeout'));
}, config.connection_timeout_ms);
ws.on('open', () => {
console.log('[SIGNALING] WebSocket opened, sending handshake...');
// Generate HMAC signature
const timestamp = Date.now();
const message = `${timestamp}:${sessionKey}`;
const signature = crypto
.createHmac('sha256', WEBHOOK_SECRET_TOKEN)
.update(message)
.digest('hex');
// Send handshake
ws.send(JSON.stringify({
type: 'handshake',
timestamp,
signature
}));
});
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'handshake_response') {
clearTimeout(timeoutId);
if (msg.status === 'success') {
console.log('[SIGNALING] Handshake successful');
resolve(ws);
} else {
ws.close();
reject(new Error(`Handshake failed: ${msg.error}`));
}
}
});
ws.on('error', (error) => {
clearTimeout(timeoutId);
reject(error);
});
ws.on('close', (code, reason) => {
clearTimeout(timeoutId);
reject(new Error(`Connection closed: ${code} ${reason}`));
});
});
}
```
## Step 4: Connect to Media WebSocket
```javascript
async function connectMediaWithRetry(mediaUrl, signalingWs) {
for (let attempt = 1; attempt <= config.connection_max_attempts; attempt++) {
console.log(`[MEDIA] Attempt ${attempt}/${config.connection_max_attempts}`);
try {
const ws = await connectMediaSocket(mediaUrl);
console.log('[MEDIA] Connected successfully');
subscribeToStreams(ws);
setupKeepAlive(ws);
return ws;
} catch (error) {
console.error(`[MEDIA] Attempt ${attempt} failed:`, error.message);
if (attempt < config.connection_max_attempts) {
const delay = config.connection_retry_delay_ms;
console.log(`[MEDIA] Retrying in ${delay}ms...`);
await sleep(delay);
}
}
}
throw new Error(
`Failed to connect media WebSocket after ${config.connection_max_attempts} attempts`
);
}
function subscribeToStreams(mediaWs) {
// Subscribe to all available streams
mediaWs.send(JSON.stringify({
type: 'subscribe',
streams: ['audio', 'video', 'transcription', 'share', 'chat']
}));
console.log('[MEDIA] Subscribed to: audio, video, transcription, share, chat');
}
```
## Step 5: Keep-Alive Management
```javascript
function setupKeepAlive(ws) {
let lastPongReceived = Date.now();
let keepAliveInterval;
let timeoutCheck;
// Send ping periodically
keepAliveInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
console.log('[KEEPALIVE] Ping sent');
}
}, config.keepalive_interval_ms);
// Check for pong timeout
timeoutCheck = setInterval(() => {
const timeSinceLastPong = Date.now() - lastPongReceived;
if (timeSinceLastPong > config.keepalive_timeout_ms) {
console.error('[KEEPALIVE] Pong timeout, closing connection');
clearInterval(keepAliveInterval);
clearInterval(timeoutCheck);
ws.close(1000, 'Keep-alive timeout');
}
}, 1000);
ws.on('pong', () => {
lastPongReceived = Date.now();
console.log('[KEEPALIVE] Pong received');
});
ws.on('close', () => {
clearInterval(keepAliveInterval);
clearInterval(timeoutCheck);
});
}
```
## Step 6: Mid-Stream Reconnection
```javascript
class ResilientRTMSConnection {
constructor(rtmsInfo, config) {
this.rtmsInfo = rtmsInfo;
this.config = config;
this.reconnectionAttempt = 0;
this.signalingWs = null;
this.mediaWs = null;
}
async connect() {
try {
this.signalingWs = await connectSignalingWithRetry(
this.rtmsInfo.signalingUrl,
this.rtmsInfo.sessionKey
);
this.mediaWs = await connectMediaWithRetry(
this.rtmsInfo.mediaUrl,
this.signalingWs
);
this.setupReconnectionHandlers();
} catch (error) {
console.error('[RTMS] Initial connection failed:', error);
throw error;
}
}
setupReconnectionHandlers() {
const handleDisconnection = (wsType) => async (code, reason) => {
console.error(`[${wsType}] Disconnected: ${code} ${reason}`);
this.reconnectionAttempt++;
if (this.reconnectionAttempt > this.config.reconnect_max_attempts) {
console.error(
`[RECONNECT] Giving up after ${this.reconnectionAttempt} attempts`
);
this.cleanup();
return;
}
// Exponential backoff: 2s, 4s, 8s...
const delay = this.config.reconnect_base_delay_ms
* Math.pow(2, this.reconnectionAttempt - 1);
console.log(
`[RECONNECT] Attempt ${this.reconnectionAttempt}/` +
`${this.config.reconnect_max_attempts} in ${delay}ms...`
);
await sleep(delay);
try {
await this.connect();
console.log('[RECONNECT] Successfully reconnected');
this.reconnectionAttempt = 0; // Reset counter
} catch (error) {
console.error('[RECONNECT] Failed:', error.message);
// Handler will be called again if connection fails
}
};
this.signalingWs.on('close', handleDisconnection('SIGNALING'));
this.mediaWs.on('close', handleDisconnection('MEDIA'));
this.signalingWs.on('error', (error) => {
console.error('[SIGNALING] Error:', error.message);
});
this.mediaWs.on('error', (error) => {
console.error('[MEDIA] Error:', error.message);
});
}
cleanup() {
if (this.signalingWs) this.signalingWs.close();
if (this.mediaWs) this.mediaWs.close();
}
}
```
### Customizing Reconnection Behavior
```javascript
// Example: Capped exponential backoff (max 30s)
const delay = Math.min(
config.reconnect_base_delay_ms * Math.pow(2, reconnectionAttempt - 1),
30000 // Cap at 30s
);
// Example: Linear backoff instead of exponential
const delay = config.reconnect_base_delay_ms * reconnectionAttempt;
// Example: Jittered backoff (avoid thundering herd)
const baseDelay = config.reconnect_base_delay_ms * Math.pow(2, reconnectionAttempt - 1);
const jitter = Math.random() * 1000; // Random 0-1000ms
const delay = baseDelay + jitter;
```
## Complete Resilient Bot Example
```javascript
const config = loadConfig();
async function main() {
try {
// 1. Optional: Trigger RTMS via REST API
console.log('[RTMS] Triggering RTMS start...');
await triggerRTMSStart(MEETING_ID);
// 2. Wait for webhook
console.log('[RTMS] Waiting for meeting.rtms_started webhook...');
const rtmsInfo = await waitForRTMSWebhook(MEETING_UUID);
// 3. Connect with resilience
const rtms = new ResilientRTMSConnection(rtmsInfo, config);
await rtms.connect();
console.log('[RTMS] Bot is running, processing streams...');
// 4. Process media data
rtms.mediaWs.on('message', (data) => {
const frame = parseMediaFrame(data);
processMediaFrame(frame);
});
// 5. Handle graceful shutdown
process.on('SIGINT', () => {
console.log('[RTMS] Shutting down...');
rtms.cleanup();
process.exit(0);
});
} catch (error) {
console.error('[RTMS] ABORT:', error.message);
process.exit(1);
}
}
main();
```
## Comparison: RTMS Bot vs Meeting SDK Bot
| Aspect | RTMS Bot | Meeting SDK Bot |
|--------|----------|-----------------|
| **Visibility** | Invisible (read-only service) | Visible participant |
| **Authentication** | REST API trigger + webhook | JWT + OBF token |
| **Join Dependency** | No dependency on participants | Owner must be present |
| **Retry Logic** | Not applicable (webhook-based) | Required (owner presence) |
| **Media Access** | Audio/video/text/share/chat via WebSocket | Raw audio/video/share via SDK |
| **Recording Control** | None (read-only) | Full (local, cloud, raw) |
| **Interaction** | Cannot interact | Can send chat, reactions |
| **Resource Usage** | Lower (WebSocket only) | Higher (full SDK) |
| **Use Case** | Passive transcription, analytics | Interactive bots, recording, moderation |
**Choose RTMS Bot when:**
- You only need to observe/transcribe
- You want minimal resource usage
- You prefer invisible operation
- You're processing external meetings (with permission)
**Choose Meeting SDK Bot when:**
- You need to interact with the meeting (chat, reactions)
- You need local recording control
- You want to be visible in participant list
- You're processing your own meetings
## Troubleshooting
### Webhook Never Arrives
**Symptom:** `waitForRTMSWebhook()` times out
**Solution:**
1. Verify webhook endpoint is HTTPS and publicly accessible
2. Check Event Subscriptions in Zoom Marketplace: `meeting.rtms_started` enabled
3. Verify RTMS was actually started (check Zoom client or REST API response)
4. Increase `webhook_wait_timeout_ms` if meeting starts later than expected
5. Test webhook delivery: `curl -X POST YOUR_WEBHOOK_URL`
### Signaling Handshake Fails
**Symptom:** Connection closes immediately after handshake
**Solution:**
1. Verify HMAC signature generation matches Zoom docs
2. Check timestamp is current (not stale)
3. Verify `WEBHOOK_SECRET_TOKEN` matches Zoom Marketplace config
4. Check signaling URL hasn't expired (short TTL)
### Keep-Alive Timeout
**Symptom:** Connection closes with "Keep-alive timeout"
**Solution:**
1. Network congestion - increase `keepalive_timeout_ms`
2. Server overloaded - increase `keepalive_interval_ms`
3. Verify ping/pong implementation is correct
4. Check firewall/proxy not blocking WebSocket pings
### Frequent Reconnections
**Symptom:** Bot reconnects multiple times, then gives up
**Solution:**
1. Increase `reconnect_max_attempts` (e.g., 5 instead of 3)
2. Increase `reconnect_base_delay_ms` if network is slow
3. Monitor server resources (CPU/memory/network)
4. Check for rate limiting (too many connection attempts)
## Resources
- **RTMS Docs**: https://developers.zoom.us/docs/rtms/
- **RTMS WebSocket Guide**: https://developers.zoom.us/docs/api/websockets/
- **RTMS SDK**: https://github.com/zoom/rtms
- **Webhook Reference**: [../references/webhooks.md](../references/webhooks.md)
- **Connection Architecture**: [../concepts/connection-architecture.md](../concepts/connection-architecture.md)
- **Meeting SDK Bot Alternative**: [Meeting SDK Bot (Linux)](../../meeting-sdk/linux/meeting-sdk-bot.md)