// Zoom Meeting SDK Linux Bot - Raw Audio → WebSocket // 完全実装: SDK認証、ミーティング参加、Raw Audioの購読とWebSocket送信 #include #include #include #include #include #include #include #include #include #include #include // WebSocket #include #include // Zoom SDK #include "zoom_sdk.h" #include "zoom_sdk_def.h" #include "auth_service_interface.h" #include "meeting_service_interface.h" #include "rawdata/rawdata_audio_helper_interface.h" #include "rawdata/zoom_rawdata_api.h" #include "zoom_sdk_raw_data_def.h" USING_ZOOM_SDK_NAMESPACE // asio_clientを使用 typedef websocketpp::client ws_client; // グローバル変数 std::string g_meetingNumber; std::string g_passcode; std::string g_displayName; std::string g_sdkKey; std::string g_sdkSecret; std::string g_wsUrl = "ws://localhost:3000/ws/audio"; std::atomic g_authenticated(false); std::atomic g_inMeeting(false); std::atomic g_running(true); ws_client* g_wsClient = nullptr; websocketpp::connection_hdl g_wsHandle; std::mutex g_wsMutex; std::atomic g_wsConnected(false); IAuthService* g_authService = nullptr; IMeetingService* g_meetingService = nullptr; // Base64 エンコード static const std::string base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz" "0123456789+/"; std::string base64_encode(const unsigned char* data, size_t len) { std::string ret; int i = 0; int j = 0; unsigned char char_array_3[3]; unsigned char char_array_4[4]; while (len--) { char_array_3[i++] = *(data++); if (i == 3) { char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); char_array_4[3] = char_array_3[2] & 0x3f; for(i = 0; i < 4; i++) ret += base64_chars[char_array_4[i]]; i = 0; } } if (i) { for(j = i; j < 3; j++) char_array_3[j] = '\0'; char_array_4[0] = (char_array_3[0] & 0xfc) >> 2; char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4); char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6); for (j = 0; j < i + 1; j++) ret += base64_chars[char_array_4[j]]; while((i++ < 3)) ret += '='; } return ret; } // HMAC-SHA256 (簡易実装 - 本番では OpenSSL を使用推奨) #include #include std::string hmac_sha256(const std::string& key, const std::string& data) { unsigned char* result; unsigned int len = 32; result = HMAC(EVP_sha256(), key.c_str(), key.length(), (unsigned char*)data.c_str(), data.length(), nullptr, nullptr); return std::string((char*)result, len); } std::string base64_url_encode(const std::string& input) { std::string output = base64_encode((const unsigned char*)input.c_str(), input.length()); // URL safe for (auto& c : output) { if (c == '+') c = '-'; else if (c == '/') c = '_'; } // パディング削除 while (!output.empty() && output.back() == '=') { output.pop_back(); } return output; } // JWT トークン生成 std::string generateJWT(const std::string& sdkKey, const std::string& sdkSecret) { auto now = std::chrono::system_clock::now(); auto iat = std::chrono::duration_cast(now.time_since_epoch()).count(); auto exp = iat + 86400; // 24時間有効 auto tokenExp = exp; // Header std::string header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; // Payload std::stringstream payload; payload << "{\"appKey\":\"" << sdkKey << "\"," << "\"iat\":" << iat << "," << "\"exp\":" << exp << "," << "\"tokenExp\":" << tokenExp << "}"; std::string headerEncoded = base64_url_encode(header); std::string payloadEncoded = base64_url_encode(payload.str()); std::string signatureInput = headerEncoded + "." + payloadEncoded; std::string signature = hmac_sha256(sdkSecret, signatureInput); std::string signatureEncoded = base64_url_encode(signature); return headerEncoded + "." + payloadEncoded + "." + signatureEncoded; } // WebSocket 接続管理 class WebSocketManager { public: void connect() { try { g_wsClient = new ws_client(); g_wsClient->clear_access_channels(websocketpp::log::alevel::all); g_wsClient->set_access_channels(websocketpp::log::alevel::connect); g_wsClient->set_access_channels(websocketpp::log::alevel::disconnect); g_wsClient->init_asio(); g_wsClient->set_open_handler([](websocketpp::connection_hdl hdl) { std::lock_guard lock(g_wsMutex); g_wsHandle = hdl; g_wsConnected = true; std::cout << "[WebSocket] Connected to " << g_wsUrl << std::endl; }); g_wsClient->set_close_handler([](websocketpp::connection_hdl) { g_wsConnected = false; std::cout << "[WebSocket] Disconnected" << std::endl; }); g_wsClient->set_fail_handler([](websocketpp::connection_hdl) { g_wsConnected = false; std::cerr << "[WebSocket] Connection failed" << std::endl; }); websocketpp::lib::error_code ec; auto con = g_wsClient->get_connection(g_wsUrl, ec); if (ec) { std::cerr << "[WebSocket] Connection error: " << ec.message() << std::endl; return; } g_wsClient->connect(con); // 別スレッドで実行 std::thread([this]() { g_wsClient->run(); }).detach(); } catch (const std::exception& e) { std::cerr << "[WebSocket] Error: " << e.what() << std::endl; } } void send(const std::string& message) { if (!g_wsConnected) return; try { std::lock_guard lock(g_wsMutex); websocketpp::lib::error_code ec; g_wsClient->send(g_wsHandle, message, websocketpp::frame::opcode::text, ec); if (ec) { std::cerr << "[WebSocket] Send error: " << ec.message() << std::endl; } } catch (const std::exception& e) { std::cerr << "[WebSocket] Send exception: " << e.what() << std::endl; } } void close() { if (g_wsClient && g_wsConnected) { websocketpp::lib::error_code ec; g_wsClient->close(g_wsHandle, websocketpp::close::status::normal, "", ec); } } }; WebSocketManager g_wsManager; // Audio Raw Data デリゲート class AudioRawDataDelegate : public IZoomSDKAudioRawDataDelegate { public: void onMixedAudioRawDataReceived(AudioRawData* data) override { if (!data || !g_wsConnected) return; char* buffer = data->GetBuffer(); unsigned int bufferLen = data->GetBufferLen(); unsigned int sampleRate = data->GetSampleRate(); unsigned int channelNum = data->GetChannelNum(); unsigned long long timestamp = data->GetTimeStamp(); // Base64 エンコード std::string b64 = base64_encode((const unsigned char*)buffer, bufferLen); // JSON ペイロード作成 std::stringstream ss; ss << "{" << "\"meetingId\":\"" << g_meetingNumber << "\"," << "\"speaker\":\"mixed\"," << "\"chunkBase64\":\"" << b64 << "\"," << "\"sampleRate\":" << sampleRate << "," << "\"channels\":" << channelNum << "," << "\"ts\":" << timestamp << "}"; g_wsManager.send(ss.str()); } void onOneWayAudioRawDataReceived(AudioRawData* data, uint32_t userId) override { if (!data || !g_wsConnected) return; char* buffer = data->GetBuffer(); unsigned int bufferLen = data->GetBufferLen(); unsigned int sampleRate = data->GetSampleRate(); unsigned int channelNum = data->GetChannelNum(); unsigned long long timestamp = data->GetTimeStamp(); std::string b64 = base64_encode((const unsigned char*)buffer, bufferLen); std::stringstream ss; ss << "{" << "\"meetingId\":\"" << g_meetingNumber << "\"," << "\"speaker\":\"user_" << userId << "\"," << "\"userId\":" << userId << "," << "\"chunkBase64\":\"" << b64 << "\"," << "\"sampleRate\":" << sampleRate << "," << "\"channels\":" << channelNum << "," << "\"ts\":" << timestamp << "}"; g_wsManager.send(ss.str()); } void onShareAudioRawDataReceived(AudioRawData* data, uint32_t userId) override { // 共有音声は必要に応じて処理 } void onOneWayInterpreterAudioRawDataReceived(AudioRawData* data, const zchar_t* pLanguageName) override { // 通訳音声は必要に応じて処理 } }; AudioRawDataDelegate* g_audioDelegate = nullptr; // Auth Service イベントハンドラ class AuthServiceEvent : public IAuthServiceEvent { public: void onAuthenticationReturn(AuthResult ret) override { if (ret == AUTHRET_SUCCESS) { std::cout << "[Auth] Authentication successful" << std::endl; g_authenticated = true; } else { std::cerr << "[Auth] Authentication failed: " << ret << std::endl; g_running = false; } } void onLoginReturnWithReason(LOGINSTATUS ret, IAccountInfo* pAccountInfo, LoginFailReason reason) override { // ログイン不要 } void onLogout() override {} void onZoomIdentityExpired() override {} void onZoomAuthIdentityExpired() override {} }; AuthServiceEvent* g_authEvent = nullptr; // Meeting Service イベントハンドラ class MeetingServiceEvent : public IMeetingServiceEvent { public: void onMeetingStatusChanged(MeetingStatus status, int iResult) override { std::cout << "[Meeting] Status changed: " << status; switch (status) { case MEETING_STATUS_IDLE: std::cout << " (Idle)" << std::endl; break; case MEETING_STATUS_CONNECTING: std::cout << " (Connecting)" << std::endl; break; case MEETING_STATUS_WAITINGFORHOST: std::cout << " (Waiting for host)" << std::endl; break; case MEETING_STATUS_INMEETING: std::cout << " (In meeting)" << std::endl; g_inMeeting = true; // Raw Audio 購読開始 subscribeAudioRawData(); break; case MEETING_STATUS_DISCONNECTING: std::cout << " (Disconnecting)" << std::endl; break; case MEETING_STATUS_RECONNECTING: std::cout << " (Reconnecting)" << std::endl; break; case MEETING_STATUS_FAILED: std::cout << " (Failed: " << iResult << ")" << std::endl; g_running = false; break; case MEETING_STATUS_ENDED: std::cout << " (Ended: " << iResult << ")" << std::endl; g_inMeeting = false; g_running = false; break; default: std::cout << std::endl; break; } } void onMeetingStatisticsWarningNotification(StatisticsWarningType type) override {} void onMeetingParameterNotification(const MeetingParameter* meeting_param) override {} void onSuspendParticipantsActivities() override {} void onAICompanionActiveChangeNotice(bool bActive) override {} void onMeetingTopicChanged(const zchar_t* sTopic) override {} void onMeetingFullToWatchLiveStream(const zchar_t* sLiveStreamUrl) override {} void onUserNetworkStatusChanged(MeetingComponentType type, ConnectionQuality level, unsigned int userId, bool uplink) override {} private: void subscribeAudioRawData() { IZoomSDKAudioRawDataHelper* audioHelper = GetAudioRawdataHelper(); if (audioHelper) { g_audioDelegate = new AudioRawDataDelegate(); SDKError err = audioHelper->subscribe(g_audioDelegate); if (err == SDKERR_SUCCESS) { std::cout << "[Audio] Subscribed to raw audio data" << std::endl; } else { std::cerr << "[Audio] Failed to subscribe: " << err << std::endl; } } else { std::cerr << "[Audio] Failed to get audio helper" << std::endl; } } }; MeetingServiceEvent* g_meetingEvent = nullptr; // SDK 初期化 bool initSDK() { InitParam initParam; initParam.strWebDomain = "https://zoom.us"; initParam.enableLogByDefault = true; initParam.enableGenerateDump = true; SDKError err = InitSDK(initParam); if (err != SDKERR_SUCCESS) { std::cerr << "[SDK] InitSDK failed: " << err << std::endl; return false; } std::cout << "[SDK] Initialized successfully" << std::endl; std::cout << "[SDK] Version: " << GetSDKVersion() << std::endl; return true; } // Auth Service 作成と認証 bool authenticate() { SDKError err = CreateAuthService(&g_authService); if (err != SDKERR_SUCCESS || !g_authService) { std::cerr << "[Auth] CreateAuthService failed: " << err << std::endl; return false; } g_authEvent = new AuthServiceEvent(); g_authService->SetEvent(g_authEvent); // JWT トークン生成 std::string jwt = generateJWT(g_sdkKey, g_sdkSecret); std::cout << "[Auth] JWT generated" << std::endl; AuthContext authContext; authContext.jwt_token = jwt.c_str(); err = g_authService->SDKAuth(authContext); if (err != SDKERR_SUCCESS) { std::cerr << "[Auth] SDKAuth failed: " << err << std::endl; return false; } // 認証完了を待つ int timeout = 30; while (!g_authenticated && timeout > 0) { std::this_thread::sleep_for(std::chrono::seconds(1)); timeout--; } return g_authenticated; } // Meeting Service 作成とミーティング参加 bool joinMeeting() { SDKError err = CreateMeetingService(&g_meetingService); if (err != SDKERR_SUCCESS || !g_meetingService) { std::cerr << "[Meeting] CreateMeetingService failed: " << err << std::endl; return false; } g_meetingEvent = new MeetingServiceEvent(); g_meetingService->SetEvent(g_meetingEvent); // 参加パラメータ設定 JoinParam joinParam; joinParam.userType = SDK_UT_WITHOUT_LOGIN; JoinParam4WithoutLogin& param = joinParam.param.withoutloginuserJoin; param.meetingNumber = std::stoull(g_meetingNumber); param.userName = g_displayName.c_str(); param.psw = g_passcode.c_str(); param.isVideoOff = true; param.isAudioOff = false; param.isMyVoiceInMix = true; param.isAudioRawDataStereo = false; param.eAudioRawdataSamplingRate = AudioRawdataSamplingRate_32K; err = g_meetingService->Join(joinParam); if (err != SDKERR_SUCCESS) { std::cerr << "[Meeting] Join failed: " << err << std::endl; return false; } std::cout << "[Meeting] Joining meeting " << g_meetingNumber << "..." << std::endl; return true; } // コマンドライン引数解析 void parseArgs(int argc, char** argv) { for (int i = 1; i < argc; i++) { std::string arg = argv[i]; if (arg == "--meeting-number" && i + 1 < argc) { g_meetingNumber = argv[++i]; } else if (arg == "--passcode" && i + 1 < argc) { g_passcode = argv[++i]; } else if (arg == "--name" && i + 1 < argc) { g_displayName = argv[++i]; } else if (arg == "--ws-url" && i + 1 < argc) { g_wsUrl = argv[++i]; } } // 環境変数からも取得 if (g_sdkKey.empty()) { const char* key = std::getenv("ZOOM_SDK_KEY"); if (key) g_sdkKey = key; } if (g_sdkSecret.empty()) { const char* secret = std::getenv("ZOOM_SDK_SECRET"); if (secret) g_sdkSecret = secret; } if (g_meetingNumber.empty()) { const char* num = std::getenv("MEETING_NUMBER"); if (num) g_meetingNumber = num; } if (g_passcode.empty()) { const char* pass = std::getenv("MEETING_PASSCODE"); if (pass) g_passcode = pass; } if (g_displayName.empty()) { const char* name = std::getenv("BOT_DISPLAY_NAME"); if (name) g_displayName = name; else g_displayName = "WhisperBot"; } } // クリーンアップ void cleanup() { std::cout << "[Cleanup] Cleaning up..." << std::endl; // WebSocket 切断 g_wsManager.close(); // Audio購読解除 IZoomSDKAudioRawDataHelper* audioHelper = GetAudioRawdataHelper(); if (audioHelper) { audioHelper->unSubscribe(); } // ミーティング退出 if (g_meetingService && g_inMeeting) { g_meetingService->Leave(LEAVE_MEETING); } // サービス破棄 if (g_meetingService) { DestroyMeetingService(g_meetingService); g_meetingService = nullptr; } if (g_authService) { DestroyAuthService(g_authService); g_authService = nullptr; } // SDK クリーンアップ CleanUPSDK(); // イベントハンドラ削除 delete g_authEvent; delete g_meetingEvent; delete g_audioDelegate; delete g_wsClient; std::cout << "[Cleanup] Done" << std::endl; } int main(int argc, char** argv) { std::cout << "=== Zoom Whisper Bot ===" << std::endl; // 引数解析 parseArgs(argc, argv); // 必須パラメータチェック if (g_sdkKey.empty() || g_sdkSecret.empty()) { std::cerr << "Error: ZOOM_SDK_KEY and ZOOM_SDK_SECRET are required" << std::endl; return 1; } if (g_meetingNumber.empty()) { std::cerr << "Error: Meeting number is required (--meeting-number or MEETING_NUMBER)" << std::endl; return 1; } std::cout << "Meeting: " << g_meetingNumber << std::endl; std::cout << "Display Name: " << g_displayName << std::endl; std::cout << "WebSocket URL: " << g_wsUrl << std::endl; // WebSocket 接続 g_wsManager.connect(); std::this_thread::sleep_for(std::chrono::seconds(2)); // SDK 初期化 if (!initSDK()) { return 1; } // 認証 if (!authenticate()) { cleanup(); return 1; } // ミーティング参加 if (!joinMeeting()) { cleanup(); return 1; } // メインループ std::cout << "[Main] Bot is running. Press Ctrl+C to exit." << std::endl; while (g_running) { std::this_thread::sleep_for(std::chrono::seconds(1)); } // クリーンアップ cleanup(); std::cout << "[Main] Bot terminated." << std::endl; return 0; }