HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/.codex/.tmp/plugins/plugins/zoom/skills/meeting-sdk/linux/meeting-sdk-bot.md
# Meeting SDK Bot (Linux)

Build resilient meeting bots that join Zoom meetings as visible participants using the Meeting SDK.

## Overview

Meeting SDK bots join as real participants (visible in participant list) and can access raw audio/video data for recording, transcription, or AI processing.

**Use this approach when:**
- You need to be visible in the participant list
- You want to interact with meeting features (chat, reactions, etc.)
- You need local recording control
- You're processing your own meetings or have user consent

**Alternative:** See [rtms/examples/rtms-bot.md](../../rtms/examples/rtms-bot.md) for invisible, read-only access via RTMS.

## Architecture

```
┌─────────────────────────────────────────────────────────────────────┐
│                    MEETING SDK BOT FLOW                              │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│ 1. Pre-Join: REST API                                               │
│    └── Get meeting schedule (number, password, start time)          │
│    └── Get OBF token for user (bot joins "on behalf of" user)       │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 2. Join with Retry (OBF requires owner present)                     │
│    └── Retry with configurable interval until owner joins           │
│    └── Circuit breaker: Stop after N attempts                       │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 3. Start Raw Recording (Meeting SDK singleton)                      │
│    └── IMeetingRecordingController::StartRawRecording()             │
│    └── Subscribe to raw audio/video                                 │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 4. Process Media Streams                                            │
│    └── Audio: PCM data via IZoomSDKAudioRawDataDelegate             │
│    └── Video: YUV420 frames via IZoomSDKVideoRawDataDelegate        │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 5. Mid-Meeting: Connection Monitoring                               │
│    └── Detect disconnections → Exponential backoff retry            │
│    └── Stop after N reconnection attempts                           │
└─────────────────────────────────────────────────────────────────────┘
```

## Skills Required

| Skill | Purpose |
|-------|---------|
| **zoom-rest-api** | Get meeting schedule, retrieve OBF token |
| **zoom-meeting-sdk** (Linux) | Join meeting, control recording, access raw media |

## Choose the Recording Mode

| Goal | Primary path | Skills |
|------|--------------|--------|
| Bot writes its own audio/video files | `StartRawRecording()` + raw audio/video delegates | `zoom-meeting-sdk` (Linux) |
| Zoom-hosted MP4/M4A/transcript assets after meeting end | Meeting/account cloud recording settings + `recording.completed` webhook + recordings download API | `zoom-rest-api` + `zoom-webhooks` |

Use raw recording when the bot must process or persist media itself. Use the cloud-recording path when the requirement is post-meeting retrieval of Zoom-managed recording assets.

## Automatic Join + Recording Flow

```text
zoom-rest-api
  -> get meeting metadata
  -> get OBF or ZAK token
Meeting SDK Linux bot
  -> join with retry
  -> CanStartRawRecording()
  -> StartRawRecording()
  -> subscribe raw audio/video
  -> write PCM/YUV or forward to AI pipeline
Optional post-meeting cloud path
  -> zoom-webhooks recording.completed
  -> zoom-rest-api recordings download
```

## Prerequisites

- Zoom Meeting SDK v5.15+ (Linux)
- Meeting SDK JWT credentials (SDK Key + Secret)
- Server-to-Server OAuth app or OAuth app for REST API access
- REST API scopes: `meeting:read`, `user:read`, optionally `meeting:write` if triggering meetings
- Raw Data entitlement enabled (Admin → Account Settings → Meeting → "Allow access to raw data")

## Configuration

### Retry Parameters (Customizable)

```cpp
// config.h or environment variables
struct BotConfig {
    // Join retry (waiting for owner to be present)
    int join_retry_attempts = 5;          // Max join attempts (default: 5)
    int join_retry_interval_ms = 60000;   // Constant interval: 60s (default: 1min)

    // Mid-meeting reconnection (network failures)
    int reconnect_max_attempts = 3;       // Max reconnection attempts (default: 3)
    int reconnect_base_delay_ms = 2000;   // Initial delay: 2s (default: 2s)
    // Exponential backoff: 2s, 4s, 8s...

    // Meeting schedule polling (if webhook unavailable)
    int schedule_poll_interval_ms = 30000; // Poll every 30s (default: 30s)

    // Timeout settings
    int auth_timeout_ms = 10000;          // SDK auth timeout (default: 10s)
    int join_timeout_ms = 30000;          // Single join attempt timeout (default: 30s)
};

// Load from environment variables (recommended for production)
BotConfig loadConfig() {
    BotConfig cfg;

    // Override defaults from env vars if present
    const char* env;

    if ((env = getenv("BOT_JOIN_RETRY_ATTEMPTS"))) {
        cfg.join_retry_attempts = atoi(env);
    }
    if ((env = getenv("BOT_JOIN_RETRY_INTERVAL_MS"))) {
        cfg.join_retry_interval_ms = atoi(env);
    }
    if ((env = getenv("BOT_RECONNECT_MAX_ATTEMPTS"))) {
        cfg.reconnect_max_attempts = atoi(env);
    }
    if ((env = getenv("BOT_RECONNECT_BASE_DELAY_MS"))) {
        cfg.reconnect_base_delay_ms = atoi(env);
    }

    return cfg;
}
```

### Customization Guide

| Parameter | Default | When to Increase | When to Decrease |
|-----------|---------|------------------|------------------|
| `join_retry_attempts` | 5 | High-priority meetings, owner often late | Testing, short-lived meetings |
| `join_retry_interval_ms` | 60000 (1min) | Meetings with long pre-join buffer | Need faster failure detection |
| `reconnect_max_attempts` | 3 | Unstable networks, critical meetings | Batch processing, cost-sensitive |
| `reconnect_base_delay_ms` | 2000 (2s) | Network latency high (international) | Local network, low latency |

**Recommended Ranges:**
- Join retry attempts: 3-10
- Join retry interval: 30s-5min
- Reconnect attempts: 2-5
- Reconnect base delay: 1s-5s

**Examples:**

```bash
# High-priority production bot (aggressive retries)
export BOT_JOIN_RETRY_ATTEMPTS=10
export BOT_JOIN_RETRY_INTERVAL_MS=30000  # 30s
export BOT_RECONNECT_MAX_ATTEMPTS=5
export BOT_RECONNECT_BASE_DELAY_MS=1000  # 1s

# Cost-sensitive batch processing (conservative)
export BOT_JOIN_RETRY_ATTEMPTS=3
export BOT_JOIN_RETRY_INTERVAL_MS=120000  # 2min
export BOT_RECONNECT_MAX_ATTEMPTS=2
export BOT_RECONNECT_BASE_DELAY_MS=5000   # 5s

# Development/testing (fail fast)
export BOT_JOIN_RETRY_ATTEMPTS=2
export BOT_JOIN_RETRY_INTERVAL_MS=10000   # 10s
export BOT_RECONNECT_MAX_ATTEMPTS=1
export BOT_RECONNECT_BASE_DELAY_MS=1000   # 1s
```

## Step 1: Get Meeting Info + OBF Token (REST API)

### Get Meeting Schedule

```bash
# Get meeting details
curl "https://api.zoom.us/v2/meetings/{meetingId}" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Response:**
```json
{
  "id": 1234567890,
  "topic": "Team Standup",
  "start_time": "2026-02-09T15:00:00Z",
  "password": "abc123",
  "pmi": false
}
```

**Store:** meeting number, password, start time.

### Get OBF Token

The bot joins "on behalf of" a Zoom user. Get the user's OBF token:

```bash
# Get OBF token for user
curl -X POST "https://api.zoom.us/v2/users/{userId}/token?type=obf&ttl=7200" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Response:**
```json
{
  "token": "eyJhbGc...",
  "expire_in": 7200
}
```

**CRITICAL:** The bot cannot join until the **owner** (the user whose OBF token you're using) is present in the meeting. This is why retry logic is essential.

**Error if REST API fails:** ABORT. No meeting info = cannot proceed.

## Step 2: Join Meeting with Retry Logic

### Join Implementation

```cpp
#include <chrono>
#include <thread>

class MeetingBot {
private:
    BotConfig config;
    IMeetingService* meetingService;
    bool joinSuccessful = false;
    bool ownerNotPresentError = false;

public:
    // Attempt to join with retry logic
    bool joinMeetingWithRetry(
        uint64_t meetingNumber,
        const string& password,
        const string& obfToken,
        const string& botDisplayName
    ) {
        for (int attempt = 1; attempt <= config.join_retry_attempts; attempt++) {
            cout << "[JOIN] Attempt " << attempt << "/"
                 << config.join_retry_attempts << endl;

            // Attempt join
            JoinParam joinParam;
            joinParam.userType = SDK_UT_WITHOUT_LOGIN;

            JoinParam4WithoutLogin& param = joinParam.param.withoutloginuserJoin;
            param.meetingNumber = meetingNumber;
            param.userName = botDisplayName.c_str();
            param.psw = password.c_str();
            param.isVideoOff = true;   // Bots typically don't send video
            param.isAudioOff = false;  // Need audio for transcription
            param.app_privilege_token = obfToken.c_str();  // OBF token

            SDKError err = meetingService->Join(joinParam);

            if (err != SDKERR_SUCCESS) {
                cerr << "[JOIN] Join() call failed: " << err << endl;

                // Check if it's a retriable error
                if (shouldRetryJoin(err)) {
                    if (attempt < config.join_retry_attempts) {
                        cout << "[JOIN] Retrying in "
                             << (config.join_retry_interval_ms / 1000)
                             << "s..." << endl;
                        this_thread::sleep_for(
                            chrono::milliseconds(config.join_retry_interval_ms)
                        );
                        continue;
                    }
                }

                // Non-retriable error or out of attempts
                cerr << "[JOIN] Giving up after " << attempt << " attempts" << endl;
                return false;
            }

            // Join() call succeeded, wait for callback
            if (waitForJoinCallback()) {
                cout << "[JOIN] Successfully joined meeting!" << endl;
                return true;
            } else {
                cerr << "[JOIN] Join callback indicated failure" << endl;

                // If owner not present, retry
                if (ownerNotPresentError && attempt < config.join_retry_attempts) {
                    cout << "[JOIN] Owner not present. Retrying in "
                         << (config.join_retry_interval_ms / 1000) << "s..." << endl;
                    this_thread::sleep_for(
                        chrono::milliseconds(config.join_retry_interval_ms)
                    );
                    continue;
                }
            }
        }

        cerr << "[JOIN] Failed to join after "
             << config.join_retry_attempts << " attempts" << endl;
        return false;
    }

private:
    bool shouldRetryJoin(SDKError err) {
        switch (err) {
            case SDKERR_WRONG_USAGE:
            case SDKERR_INVALID_PARAMETER:
            case SDKERR_MODULE_LOAD_FAILED:
                return false;  // Configuration errors, don't retry

            case SDKERR_NETWORK_ERROR:
            case SDKERR_SERVICE_FAILED:
                return true;   // Network/server issues, retry

            default:
                return true;   // Unknown error, retry to be safe
        }
    }

    bool waitForJoinCallback() {
        // Wait for onMeetingStatusChanged callback
        // Implementation depends on your callback handling
        // Typical: use condition variable with timeout

        std::unique_lock<std::mutex> lock(callbackMutex);
        bool success = callbackCV.wait_for(
            lock,
            std::chrono::milliseconds(config.join_timeout_ms),
            [this]{ return joinSuccessful || ownerNotPresentError; }
        );

        return success && joinSuccessful;
    }

public:
    // Callback from SDK
    void onMeetingStatusChanged(MeetingStatus status, int iResult) {
        std::lock_guard<std::mutex> lock(callbackMutex);

        if (status == MEETING_STATUS_INMEETING) {
            joinSuccessful = true;
            callbackCV.notify_one();
        } else if (status == MEETING_STATUS_FAILED) {
            // Check error code for "owner not present"
            if (iResult == MEETING_FAIL_OBF_OWNER_NOT_IN_MEETING) {
                ownerNotPresentError = true;
            }
            joinSuccessful = false;
            callbackCV.notify_one();
        }
    }

private:
    std::mutex callbackMutex;
    std::condition_variable callbackCV;
};
```

### Common Join Errors

| Error Code | Meaning | Action |
|------------|---------|--------|
| `MEETING_FAIL_OBF_OWNER_NOT_IN_MEETING` | OBF token owner not in meeting yet | RETRY (owner might join soon) |
| `MEETING_FAIL_MEETING_NOT_EXIST` | Meeting not started | RETRY if before end time |
| `MEETING_FAIL_INCORRECT_MEETING_NUMBER` | Wrong meeting ID | ABORT (config error) |
| `MEETING_FAIL_MEETING_NOT_START` | Meeting hasn't started | RETRY until start time |
| `MEETING_FAIL_INVALID_TOKEN` | OBF token invalid/expired | ABORT (need new token) |

## Step 3: Start Raw Recording

Once joined, request permission to access raw audio/video:

```cpp
void onMeetingStatusChanged(MeetingStatus status, int iResult) {
    if (status == MEETING_STATUS_INMEETING) {
        cout << "[BOT] Joined successfully, starting raw recording..." << endl;

        // Get recording controller
        IMeetingRecordingController* recordCtrl =
            meetingService->GetMeetingRecordingController();

        if (!recordCtrl) {
            cerr << "[BOT] Failed to get recording controller" << endl;
            return;
        }

        // Check permission
        SDKError canRecord = recordCtrl->CanStartRawRecording();
        if (canRecord != SDKERR_SUCCESS) {
            cerr << "[BOT] Cannot start raw recording: " << canRecord << endl;
            cerr << "[BOT] Check: Raw Data entitlement enabled in Admin settings?" << endl;
            return;
        }

        // Start raw recording (enables raw data flow)
        SDKError err = recordCtrl->StartRawRecording();
        if (err != SDKERR_SUCCESS) {
            cerr << "[BOT] StartRawRecording failed: " << err << endl;
            return;
        }

        cout << "[BOT] Raw recording started, subscribing to media..." << endl;

        // Subscribe to audio/video
        subscribeToRawMedia();
    }
}
```

**IMPORTANT:** `StartRawRecording()` does NOT create a file. It enables access to raw audio/video data streams.

### Manage Recording Lifecycle Explicitly

The bot should treat raw recording as a capability switch plus media subscriptions:

```cpp
class RecordingSession {
public:
    void start(IMeetingService* meetingService) {
        auto* recordCtrl = meetingService->GetMeetingRecordingController();
        if (!recordCtrl) {
            throw std::runtime_error("recording_controller_unavailable");
        }

        SDKError canRecord = recordCtrl->CanStartRawRecording();
        if (canRecord != SDKERR_SUCCESS) {
            throw std::runtime_error("raw_recording_not_permitted");
        }

        SDKError err = recordCtrl->StartRawRecording();
        if (err != SDKERR_SUCCESS) {
            throw std::runtime_error("start_raw_recording_failed");
        }

        audioHelper = GetAudioRawdataHelper();
        audioHelper->subscribe(&audioDelegate, true);

        createRenderer(&videoRenderer, &videoDelegate);
        videoRenderer->setRawDataResolution(ZoomSDKResolution_720P);
        videoRenderer->subscribe(activeUserId, RAW_DATA_TYPE_VIDEO);
    }

    void stop(IMeetingService* meetingService) {
        if (audioHelper) {
            audioHelper->unSubscribe();
        }
        if (videoRenderer) {
            videoRenderer->unSubscribe();
        }

        auto* recordCtrl = meetingService->GetMeetingRecordingController();
        if (recordCtrl) {
            recordCtrl->StopRawRecording();
        }
    }

private:
    IZoomSDKAudioRawDataHelper* audioHelper = nullptr;
    IZoomSDKRenderer* videoRenderer = nullptr;
    MyAudioDelegate audioDelegate;
    MyVideoDelegate videoDelegate;
    uint32_t activeUserId = 0;
};
```

Persisting a recording is your job after raw data arrives. Typical outputs are:

- PCM audio -> WAV/FLAC encoder or streaming transcription pipeline
- YUV420 video -> FFmpeg transcode to MP4 or frame-by-frame CV pipeline
- Mixed bot pipeline -> raw capture first, then post-process after leave

## Step 4: Subscribe to Raw Audio/Video

```cpp
void subscribeToRawMedia() {
    // Subscribe to raw audio
    IZoomSDKAudioRawDataDelegate* audioDelegate = new MyAudioDelegate();
    SDKError audioErr = meetingService->GetMeetingAudioController()
        ->GetMeetingAudioHelper()
        ->subscribe(audioDelegate, true);  // true = mixed audio

    if (audioErr != SDKERR_SUCCESS) {
        cerr << "[AUDIO] Subscribe failed: " << audioErr << endl;
    } else {
        cout << "[AUDIO] Subscribed to mixed audio" << endl;
    }

    // Subscribe to raw video
    IZoomSDKVideoRawDataDelegate* videoDelegate = new MyVideoDelegate();
    SDKError videoErr = meetingService->GetMeetingVideoController()
        ->GetMeetingVideoHelper()
        ->subscribe(videoDelegate);

    if (videoErr != SDKERR_SUCCESS) {
        cerr << "[VIDEO] Subscribe failed: " << videoErr << endl;
    } else {
        cout << "[VIDEO] Subscribed to video streams" << endl;
    }
}
```

### Cloud Recording Alternative

If the requirement is **Zoom-managed cloud recording** instead of raw media capture, use Meeting SDK only for the joining bot and use API/webhook skills for the recording workflow:

```bash
curl -X POST "https://api.zoom.us/v2/users/{userId}/meetings" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "topic": "Bot Recorded Meeting",
    "type": 2,
    "start_time": "2026-03-06T18:00:00Z",
    "settings": {
      "auto_recording": "cloud"
    }
  }'
```

Then subscribe to `recording.completed` and download assets through the recordings APIs:

- `zoom-webhooks` -> receive `recording.completed`
- `zoom-rest-api` -> `GET /meetings/{meetingId}/recordings` or `GET /users/{userId}/recordings`

Use this path when the desired output is Zoom-hosted MP4/M4A/transcript files rather than bot-owned raw PCM/YUV.

## Step 5: Mid-Meeting Reconnection

### Connection Monitoring

```cpp
class MeetingBot {
private:
    BotConfig config;
    int reconnectionAttempt = 0;

public:
    void onMeetingStatusChanged(MeetingStatus status, int iResult) {
        switch (status) {
            case MEETING_STATUS_RECONNECTING:
                cout << "[BOT] Connection lost, SDK is reconnecting..." << endl;
                break;

            case MEETING_STATUS_FAILED:
            case MEETING_STATUS_DISCONNECTING:
                handleDisconnection(iResult);
                break;

            case MEETING_STATUS_INMEETING:
                // Reconnection successful
                if (reconnectionAttempt > 0) {
                    cout << "[BOT] Reconnected successfully!" << endl;
                    reconnectionAttempt = 0;  // Reset counter
                }
                break;
        }
    }

private:
    void handleDisconnection(int errorCode) {
        reconnectionAttempt++;

        cout << "[RECONNECT] Disconnected (error: " << errorCode
             << "), attempt " << reconnectionAttempt << "/"
             << config.reconnect_max_attempts << endl;

        if (reconnectionAttempt >= config.reconnect_max_attempts) {
            cerr << "[RECONNECT] Giving up after "
                 << reconnectionAttempt << " attempts" << endl;
            cleanup();
            notifyFailure("Max reconnection attempts exceeded");
            return;
        }

        // Exponential backoff: 2s, 4s, 8s...
        int delay_ms = config.reconnect_base_delay_ms
                       * (1 << (reconnectionAttempt - 1));

        cout << "[RECONNECT] Retrying in " << (delay_ms / 1000) << "s..." << endl;

        // Schedule reconnection
        std::thread([this, delay_ms]() {
            std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
            attemptRejoin();
        }).detach();
    }

    void attemptRejoin() {
        // Re-use same meeting number, password, OBF token
        // If OBF token expired, fetch new one from REST API first

        if (isOBFTokenExpired()) {
            cout << "[RECONNECT] OBF token expired, fetching new one..." << endl;
            // TODO: Call REST API to get fresh OBF token
        }

        // Call join again
        bool success = joinMeetingWithRetry(
            cachedMeetingNumber,
            cachedPassword,
            cachedOBFToken,
            cachedBotName
        );

        if (!success) {
            cerr << "[RECONNECT] Rejoin failed" << endl;
            cleanup();
            notifyFailure("Reconnection failed");
        }
    }
};
```

### Customizing Reconnection Behavior

```cpp
// Example: Linear backoff instead of exponential
int delay_ms = config.reconnect_base_delay_ms * reconnectionAttempt;

// Example: Capped exponential backoff (max 30s)
int delay_ms = std::min(
    config.reconnect_base_delay_ms * (1 << (reconnectionAttempt - 1)),
    30000  // Cap at 30s
);

// Example: Jittered backoff (avoid thundering herd)
int base_delay = config.reconnect_base_delay_ms * (1 << (reconnectionAttempt - 1));
int jitter = rand() % 1000;  // Random 0-1000ms
int delay_ms = base_delay + jitter;
```

## Recording Fallback: Local vs Cloud

If raw recording fails, fall back to local or cloud recording:

```cpp
void startRecordingWithFallback() {
    IMeetingRecordingController* ctrl =
        meetingService->GetMeetingRecordingController();

    // Try raw recording first
    if (ctrl->CanStartRawRecording() == SDKERR_SUCCESS) {
        SDKError err = ctrl->StartRawRecording();
        if (err == SDKERR_SUCCESS) {
            cout << "[RECORDING] Using raw recording" << endl;
            return;
        }
    }

    // Fallback: Local recording
    if (ctrl->CanStartRecording(true) == SDKERR_SUCCESS) {
        SDKError err = ctrl->StartRecording(
            chrono::system_clock::now(),
            "/tmp/bot_recording.mp4"
        );
        if (err == SDKERR_SUCCESS) {
            cout << "[RECORDING] Using local recording" << endl;
            return;
        }
    }

    // Fallback: Cloud recording
    if (ctrl->CanStartCloudRecording() == SDKERR_SUCCESS) {
        SDKError err = ctrl->StartCloudRecording();
        if (err == SDKERR_SUCCESS) {
            cout << "[RECORDING] Using cloud recording" << endl;
            return;
        }
    }

    cerr << "[RECORDING] All recording methods failed" << endl;
}
```

## Complete Resilient Bot Example

```cpp
int main() {
    // 1. Load configuration
    BotConfig config = loadConfig();

    // 2. Initialize Meeting SDK
    InitParam initParam;
    initParam.strWebDomain = "https://zoom.us";
    initParam.emLanguageID = LANGUAGE_English;
    initParam.enableLogByDefault = true;

    SDKError err = InitSDK(initParam);
    if (err != SDKERR_SUCCESS) {
        cerr << "InitSDK failed: " << err << endl;
        return 1;
    }

    // 3. Authenticate SDK with JWT
    AuthContext authCtx;
    authCtx.jwt_token = generateJWT(SDK_KEY, SDK_SECRET);

    IAuthService* authService = CreateAuthService();
    authService->SDKAuth(authCtx);

    // Wait for auth callback...

    // 4. Fetch meeting info + OBF token from REST API
    MeetingInfo meetingInfo = fetchMeetingInfoFromAPI(MEETING_ID);
    string obfToken = fetchOBFTokenFromAPI(USER_ID);

    if (meetingInfo.empty() || obfToken.empty()) {
        cerr << "ABORT: Failed to get meeting info or OBF token" << endl;
        return 1;
    }

    // 5. Join meeting with retry
    MeetingBot bot(config);
    bool joined = bot.joinMeetingWithRetry(
        meetingInfo.number,
        meetingInfo.password,
        obfToken,
        "Transcription Bot"
    );

    if (!joined) {
        cerr << "ABORT: Failed to join meeting" << endl;
        return 1;
    }

    // 6. SDK callbacks handle: StartRawRecording, subscribe, reconnection

    // 7. Keep running until meeting ends
    bot.runEventLoop();

    // 8. Cleanup
    bot.cleanup();
    CleanUPSDK();

    return 0;
}
```

## Comparison: Meeting SDK Bot vs RTMS Bot

| Aspect | Meeting SDK Bot | RTMS Bot |
|--------|----------------|----------|
| **Visibility** | Visible participant | Invisible (read-only service) |
| **Authentication** | JWT + OBF token | REST API trigger + webhook |
| **Join Dependency** | Owner must be present | No dependency on participants |
| **Retry Logic** | Required (owner presence) | Not applicable (webhook-based) |
| **Media Access** | Raw audio/video/share via SDK | Audio/video/text/share/chat via WebSocket |
| **Recording Control** | Full (local, cloud, raw) | None (read-only) |
| **Interaction** | Can send chat, reactions | Cannot interact |
| **Resource Usage** | Higher (full SDK) | Lower (WebSocket only) |
| **Use Case** | Interactive bots, recording, moderation | Passive transcription, analytics |

**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

**Choose RTMS Bot when:**
- You only need to observe/transcribe
- You want minimal resource usage
- You prefer invisible operation
- You're processing external meetings

## Troubleshooting

### Bot Never Joins (OBF Owner Not Present)

**Symptom:** All join attempts fail with "owner not in meeting"

**Solution:**
1. Verify owner (OBF token holder) has joined the meeting
2. Increase `join_retry_attempts` or `join_retry_interval_ms`
3. Check meeting start time - don't attempt join before meeting starts
4. Consider webhook: Listen for `meeting.participant_joined` event for owner

### Raw Recording Permission Denied

**Symptom:** `CanStartRawRecording()` returns error

**Solution:**
1. Admin → Account Settings → Meeting → In Meeting (Advanced)
2. Enable "Allow access to raw data (audio, video, sharing) for Meeting SDK"
3. Requires "Raw Data" entitlement from Zoom support

### Frequent Disconnections During Meeting

**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. Check server network stability
4. Monitor CPU/memory usage (insufficient resources can cause disconnects)

### OBF Token Expires During Meeting

**Symptom:** Reconnection fails with "invalid token"

**Solution:**
1. Fetch fresh OBF token in `attemptRejoin()` before rejoining
2. Monitor token expiration (`expire_in` from API response)
3. Request longer-lived tokens (max TTL: 7200s = 2 hours)

## Resources

- **Meeting SDK Linux**: https://developers.zoom.us/docs/meeting-sdk/linux/
- **Raw Data Guide**: https://developers.zoom.us/docs/meeting-sdk/linux/add-features/raw-data/
- **OBF FAQ**: https://developers.zoom.us/docs/meeting-sdk/obf-faq/
- **REST API - Get Meeting**: https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meeting
- **REST API - Get OBF Token**: https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/userToken
- **RTMS Bot Alternative**: [../../rtms/examples/rtms-bot.md](../../rtms/examples/rtms-bot.md)