一、在zoom marketplace创建通用app,zoom-recall 详见Zoom会议机器人转写例子-CSDN博客
二、mac下按照Xcode,创建APP项目meetingbot4ios
三、本实用的SDK为MobileRTC,即Meeting SDK的iOS版本
四、依赖如下:
MobileRTC和CryptoSwift
五、所有代码如下(meetingbot4iosApp.swift):
import SwiftUI
import MobileRTC
import CryptoSwift
@main
struct MeetingBot: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var meetingInfo = MeetingInfo()
var body: some Scene {
WindowGroup {
ContentView(meetingInfo: meetingInfo, appDelegate: appDelegate)
}
}
}
struct ZoomMeetingView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = MobileRTC.shared().getMeetingService()?.meetingView() ?? UIView()
if view.subviews.isEmpty {
print("Meeting view is empty")
} else {
print("Meeting view has subviews")
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
// Update the view if needed
}
}
class MeetingInfo: ObservableObject {
@Published var meetingUrl: String = "https://us05web.zoom.us/j/81334539494?pwd=sf96p7am967Oc3GI39J1yLWSPa6WnS.1"
}
struct ContentView: View {
@ObservedObject var meetingInfo: MeetingInfo
@ObservedObject var appDelegate: AppDelegate
init(meetingInfo: MeetingInfo, appDelegate: AppDelegate) {
self.meetingInfo = meetingInfo
self.appDelegate = appDelegate
self.appDelegate.meetingInfo = meetingInfo
}
var body: some View {
VStack {
TextField("Enter Meeting URL", text: $meetingInfo.meetingUrl)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
// 显示会议状态
Text("Meeting State: \(appDelegate.meetingState)")
.padding()
Button(action: {
// Trigger initialization with the entered meeting URL
self.appDelegate.initializeMobileRTCWithMeetingUrl(meetingInfo.meetingUrl)
}) {
Text("Join Meeting")
}
.padding()
Button(action: {
appDelegate.toggleRecording()
}) {
Text(appDelegate.recordingState == "stopped" ? "Start Recording" : "Stop Recording")
}
.padding()
Text(appDelegate.transcriptText)
.padding()
ScrollView {
Text(appDelegate.transcript.joined(separator: "\n"))
.padding()
}
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, MobileRTCAuthDelegate, MobileRTCMeetingServiceDelegate {
@Published var meetingNumber: String?
@Published var password: String?
@Published var recordingState: String = "stopped"
@Published var transcript: [String] = []
@Published var botId: String?
@Published var meetingState: String = "Not Joined" // 新增会议状态属性
@Published var transcriptText = "Loading..."
var meetingInfo: MeetingInfo?
var window: UIWindow?
private var refreshTimer: Timer?
private var ZM_CLIENT_ID = ""
private var ZM_CLIENT_SECRET = ""
private var recallApiKey = ""
private var WEBHOOK_SECRET=""
private var PUBLIC_URL=""
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
// Initialize MobileRTC SDK
let context = MobileRTCSDKInitContext()
context.domain = "zoom.us"
context.enableLog = true
let success = MobileRTC.shared().initialize(context)
if success {
print("MobileRTC SDK initialized successfully")
} else {
print("Failed to initialize MobileRTC SDK")
}
return true
}
func initializeMobileRTCWithMeetingUrl(_ meetingUrl: String) {
// Parse meeting URL
guard let (meetingNumber, password) = parseMeetingUrl(meetingUrl) else {
print("Failed to parse meeting URL")
return
}
self.meetingNumber = meetingNumber
self.password = password
// Generate JWT
guard let jwt = generateSDKJWT(meetingNumber: meetingNumber, role: 0, expirationSeconds: 3600) else {
print("Failed to generate JWT")
return
}
// Set up the Zoom SDK with JWT
let authService = MobileRTC.shared().getAuthService()
authService?.delegate = self
authService?.jwtToken = jwt
authService?.sdkAuth()
}
private func parseMeetingUrl(_ link: String) -> (String, String)? {
// 查找会议号和密码
let meetingNumberPattern = "/j/(\\d+)"
let passwordPattern = "pwd=([^&]+)"
// 使用正则表达式来匹配会议号
if let meetingNumberRange = link.range(of: meetingNumberPattern, options: .regularExpression) {
let meetingNumberSubstring = link[meetingNumberRange]
let meetingNumber = meetingNumberSubstring.replacingOccurrences(of: "/j/", with: "")
// 使用正则表达式来匹配密码
if let passwordRange = link.range(of: passwordPattern, options: .regularExpression) {
let passwordSubstring = link[passwordRange]
let password = passwordSubstring.replacingOccurrences(of: "pwd=", with: "")
print(meetingNumber, password)
return (meetingNumber, password)
}
}
return nil
}
private func generateSDKJWT(meetingNumber: String, role: Int, expirationSeconds: Int?) -> String? {
let iat = Int(Date().timeIntervalSince1970)
let exp = expirationSeconds != nil ? iat + expirationSeconds! : iat + 60 * 60 * 2
let header = ["alg": "HS256", "typ": "JWT"]
let payload: [String: Any] = [
"appKey": ZM_CLIENT_ID,
"sdkKey": ZM_CLIENT_ID,
"mn": meetingNumber,
"role": role,
"iat": iat,
"exp": exp,
"tokenExp": exp
]
guard let headerData = try? JSONSerialization.data(withJSONObject: header, options: []),
let payloadData = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
return nil
}
let headerBase64 = headerData.base64EncodedString().replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
let payloadBase64 = payloadData.base64EncodedString().replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
let toSign = "\(headerBase64).\(payloadBase64)"
guard let hmac = try? HMAC(key: ZM_CLIENT_SECRET.bytes, variant: .sha256).authenticate(toSign.bytes) else {
return nil
}
let signatureBase64 = hmac.toBase64().replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
let jwt = "\(toSign).\(signatureBase64)"
return jwt
}
// MobileRTCAuthDelegate
func onMobileRTCAuthReturn(_ returnValue: MobileRTCAuthError) {
if returnValue == .success {
print("Zoom SDK authentication successful")
joinMeeting()
} else {
print("Zoom SDK authentication failed with error: \(returnValue)")
}
}
// MobileRTCMeetingServiceDelegate
func onMeetingStateChange(_ state: MobileRTCMeetingState) {
let stateValue = state.rawValue
print("Meeting state changed: \(stateValue)")
switch stateValue {
case 1:
meetingState = "Connecting to Meeting Server"
case 3:
meetingState = "Promoting Participant to Host"
case 4:
meetingState = "Demoting Host to Participant"
case 5:
meetingState = "Disconnecting from Meeting"
case 6:
meetingState = "Reconnecting to Meeting"
case 7:
meetingState = "Connection Failed"
case 10:
meetingState = "Meeting Locked"
case 12:
meetingState = "Participant in Waiting Room"
default:
meetingState = "Other Meeting State"
}
// Update the ZoomMeetingView based on the meeting state
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
private func joinMeeting() {
guard let meetingNumber = self.meetingNumber, let password = self.password else {
print("Meeting number or password is missing")
return
}
let meetingService = MobileRTC.shared().getMeetingService()
meetingService?.delegate = self
let joinParam = MobileRTCMeetingJoinParam()
joinParam.meetingNumber = meetingNumber
joinParam.password = password
joinParam.userName = "iOS User"
joinParam.noAudio = true
joinParam.noVideo = false
let result = meetingService?.joinMeeting(with: joinParam)
if result == .success {
print("Joining meeting...")
} else {
print("Failed to join meeting with error: \(result ?? .unknown)")
}
}
func toggleRecording() {
if recordingState == "stopped" {
startRecording()
} else {
stopRecording()
}
}
func startRecording() {
recordingState = "starting"
guard let meetingUrl = URL(string: meetingInfo?.meetingUrl ?? "") else {
print("Invalid meeting URL.")
return
}
let url = "https://us-west-2.recall.ai/api/v1/bot"
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = "POST"
request.addValue("Token \(recallApiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"bot_name": "meeetingbot",
"meeting_url": meetingUrl.absoluteString,
"transcription_options": [
"provider": "assembly_ai"
],
"real_time_transcription": [
"destination_url": PUBLIC_URL+"/webhook/transcription?secret="+WEBHOOK_SECRET,
"partial_results": true
],
"zoom": [
"request_recording_permission_on_host_join": true,
"require_recording_permission": true
]
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: .prettyPrinted)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard let data = data, error == nil else {
print("Error: \(error?.localizedDescription ?? "Unknown error")")
return
}
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode <= 299 {
let bot = try? JSONDecoder().decode(BotResponse.self, from: data)
DispatchQueue.main.async {
self.botId = bot?.id
self.recordingState = "bot-joining"
print("startRecording:",self.botId)
print("stopRecording:",self.recordingState)
// 启动定时器
self.refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.fetchTranscript()
}
}
} else {
DispatchQueue.main.async {
self.recordingState = "error"
print("startRecording:",self.recordingState)
print("startRecording:",httpResponse.statusCode)
}
}
}
}
task.resume()
}
func stopRecording() {
guard let botId = botId else {
print("No botId to stop recording")
// 恢复到 startRecording 的状态或提供适当的错误处理
DispatchQueue.main.async {
self.recordingState = "stopped"
print("stopRecording: No botId, resetting to stopped state")
}
return
}
recordingState = "stopping"
let url = "https://us-west-2.recall.ai/api/v1/bot/\(botId)/leave_call"
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = "POST"
request.addValue("Token \(recallApiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard let _ = data, error == nil else {
print("Error: \(error?.localizedDescription ?? "Unknown error")")
return
}
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode <= 299 {
DispatchQueue.main.async {
self.recordingState = "bot-leaving"
self.recordingState = "stopped"
print("stopRecording:",self.recordingState)
// 停止定时器
self.refreshTimer?.invalidate()
self.refreshTimer = nil
}
} else {
DispatchQueue.main.async {
self.recordingState = "error"
print("stopRecording:",self.recordingState)
print("stopRecording:",httpResponse.statusCode)
}
}
}
}
task.resume()
}
func fetchTranscript() {
guard let botId = botId else {
print("No botId to fetchTranscript")
return
}
let url = "https://us-west-2.recall.ai/api/v1/bot/\(botId)/transcript/?enhanced_diarization=true"
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = "GET"
request.addValue("Token \(recallApiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
self.updateTranscriptText(text: "Error: \(error.localizedDescription)")
return
}
guard let data = data else {
self.updateTranscriptText(text: "No data received")
return
}
do {
let transcripts = try JSONDecoder().decode([Transcript].self, from: data)
var transcriptText = ""
for transcript in transcripts {
transcriptText += "Speaker: \(transcript.speaker)\n"
for word in transcript.words {
transcriptText += "\(word.text) "
}
transcriptText += "\n"
}
self.updateTranscriptText(text: transcriptText)
} catch {
self.updateTranscriptText(text: "Decoding error: \(error.localizedDescription)")
}
}.resume()
}
func updateTranscriptText(text: String) {
DispatchQueue.main.async {
self.transcriptText = text
}
}
}
// 定义 JSON 数据的结构
struct Transcript: Codable {
let words: [Word]
let speaker: String
let speaker_id: Int
let language: String?
}
struct Word: Codable {
let text: String
let start_timestamp: Double
let end_timestamp: Double
let language: String?
let confidence: Double?
}
struct BotResponse: Codable {
let id: String
}
六、在远程(海外)启动zoom客户端新建一个会议
七、复制会议地址
八、运行ios程序,输入会议地址,加入会议,开始转录(最终效果)
九、由于本机远程重定向了语音,所以iOS User的语音没有打开(会导致程序崩溃)。