diff --git a/bbb-common-web/.gitignore b/bbb-common-web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..82f02806ec59d06e2e53a6be5bcbc81c410cb817 --- /dev/null +++ b/bbb-common-web/.gitignore @@ -0,0 +1,56 @@ +.DS_Store +._.DS_Store* +.metadata +.project +.classpath +.settings +.history +.worksheet +gen +**/*.swp +**/*~.nib +**/build/ +**/*.pbxuser +**/*.perspective +**/*.perspectivev3 +*.xcworkspace +*.xcuserdatad +**/target +target +*.iml +project/*.ipr +project/*.iml +project/*.iws +project/out +project/*/target +project/target +project/*/bin +project/*/build +project/*.iml +project/*/*.iml +project/.idea +project/.idea/* +.idea +.idea/* +.idea/**/* +.DS_Store +project/.DS_Store +project/*/.DS_Store +tm.out +tmlog*.log +*.tm*.epoch +out/ +provisioning/.vagrant +provisioning/*/.vagrant +provisioning/*/*.known +/sbt/akka-patterns-store/ +/daemon/src/build/ +*.lock +log/ +tmp/ +build/ +akka-patterns-store/ +lib_managed/ +.cache +bin/ + diff --git a/bbb-common-web/README.md b/bbb-common-web/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3cfa0f7331e42fc6afea656bb4bd0321478bce08 --- /dev/null +++ b/bbb-common-web/README.md @@ -0,0 +1 @@ + see http://code.google.com/p/bigbluebutton/wiki/DevelopingBBB diff --git a/bbb-common-web/build.sbt b/bbb-common-web/build.sbt new file mode 100755 index 0000000000000000000000000000000000000000..1e694fb23f03d29da0e8aa109d9147b020346bac --- /dev/null +++ b/bbb-common-web/build.sbt @@ -0,0 +1,90 @@ +name := "bbb-common-web" + +organization := "org.bigbluebutton" + +version := "0.0.1-SNAPSHOT" + +// We want to have our jar files in lib_managed dir. +// This way we'll have the right path when we import +// into eclipse. +retrieveManaged := true + +testOptions in Test += Tests.Argument(TestFrameworks.Specs2, "html", "console", "junitxml") + +testOptions in Test += Tests.Argument(TestFrameworks.ScalaTest, "-h", "target/scalatest-reports") + +libraryDependencies += "commons-lang" % "commons-lang" % "2.5" +libraryDependencies += "org.freemarker" % "freemarker" % "2.3.23" + +libraryDependencies += "org.pegdown" % "pegdown" % "1.4.0" % "test" +libraryDependencies += "junit" % "junit" % "4.12" % "test" +libraryDependencies += "com.novocode" % "junit-interface" % "0.11" % "test" + +// https://mvnrepository.com/artifact/org.mockito/mockito-core +libraryDependencies += "org.mockito" % "mockito-core" % "2.7.12" % "test" +libraryDependencies += "org.scalactic" %% "scalactic" % "3.0.1" % "test" +libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.1" % "test" + +seq(Revolver.settings: _*) + +//----------- +// Packaging +// +// Reference: +// http://xerial.org/blog/2014/03/24/sbt/ +// http://www.scala-sbt.org/sbt-pgp/usage.html +// http://www.scala-sbt.org/0.13/docs/Using-Sonatype.html +// http://central.sonatype.org/pages/requirements.html +// http://central.sonatype.org/pages/releasing-the-deployment.html +//----------- + +// Build pure Java lib (i.e. without scala) +// Do not append Scala versions to the generated artifacts +crossPaths := false + +// This forbids including Scala related libraries into the dependency +autoScalaLibrary := false + +/*************************** +* When developing, change the version above to x.x.x-SNAPSHOT then use the file resolver to +* publish to the local maven repo using "sbt publish" +*/ +// Uncomment this to publish to local maven repo while commenting out the nexus repo +publishTo := Some(Resolver.file("file", new File(Path.userHome.absolutePath+"/.m2/repository"))) + + +// Comment this out when publishing to local maven repo using SNAPSHOT version. +// To push to sonatype "sbt publishSigned" +//publishTo := { +// val nexus = "https://oss.sonatype.org/" +// if (isSnapshot.value) +// Some("snapshots" at nexus + "content/repositories/snapshots") +// else +// Some("releases" at nexus + "service/local/staging/deploy/maven2") +//} + +// Enables publishing to maven repo +publishMavenStyle := true + +publishArtifact in Test := false + +pomIncludeRepository := { _ => false } + +pomExtra := ( + <scm> + <url>git@github.com:bigbluebutton/bigbluebutton.git</url> + <connection>scm:git:git@github.com:bigbluebutton/bigbluebutton.git</connection> + </scm> + <developers> + <developer> + <id>ritzalam</id> + <name>Richard Alam</name> + <url>http://www.bigbluebutton.org</url> + </developer> + </developers>) + +licenses := Seq("LGPL-3.0" -> url("http://opensource.org/licenses/LGPL-3.0")) + +homepage := Some(url("http://www.bigbluebutton.org")) + + diff --git a/bbb-common-web/project/Build.scala b/bbb-common-web/project/Build.scala new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bbb-common-web/project/build.properties b/bbb-common-web/project/build.properties new file mode 100755 index 0000000000000000000000000000000000000000..a6e117b61042ee81c62ba3a0fc5210d9502944df --- /dev/null +++ b/bbb-common-web/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.8 diff --git a/bbb-common-web/project/plugins.sbt b/bbb-common-web/project/plugins.sbt new file mode 100755 index 0000000000000000000000000000000000000000..d33e342247ce264e1f5fd861cf0b88d3adcfca43 --- /dev/null +++ b/bbb-common-web/project/plugins.sbt @@ -0,0 +1,8 @@ +addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2") + +addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.2.0") + +addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") + + + diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Config.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Config.java similarity index 100% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Config.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Config.java diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Extension.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Extension.java old mode 100644 new mode 100755 similarity index 100% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Extension.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Extension.java diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Meeting.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java similarity index 94% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Meeting.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java index 6af99082cbbf470e4ff13a21f3ace050090c737c..733d18432ecf28d643188335dfae6ded21198eb2 100755 --- a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Meeting.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java @@ -19,20 +19,14 @@ package org.bigbluebutton.api.domain; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; - import org.apache.commons.lang.RandomStringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; + public class Meeting { - private static Logger log = LoggerFactory.getLogger(Meeting.class); - + private static final long MILLIS_IN_A_MINUTE = 60000; private String name; @@ -68,6 +62,7 @@ public class Meeting { private final ConcurrentMap<String, Long> registeredUsers; private final ConcurrentMap<String, Config> configs; private final Boolean isBreakout; + private final List<String> breakoutRooms = new ArrayList(); private long lastUserLeftOn = 0; @@ -102,6 +97,14 @@ public class Meeting { configs = new ConcurrentHashMap<String, Config>(); } + public void addBreakoutRoom(String meetingId) { + breakoutRooms.add(meetingId); + } + + public List<String> getBreakoutRooms() { + return breakoutRooms; + } + public String storeConfig(boolean defaultConfig, String config) { String token = RandomStringUtils.randomAlphanumeric(8); while (configs.containsKey(token)) { diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Playback.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Playback.java similarity index 100% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Playback.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Playback.java diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Poll.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Poll.java similarity index 100% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Poll.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Poll.java diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Recording.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Recording.java similarity index 100% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Recording.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Recording.java diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Recordings.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Recordings.java similarity index 100% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/Recordings.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Recordings.java diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/User.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/User.java similarity index 100% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/User.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/User.java diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/domain/UserSession.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/UserSession.java similarity index 100% rename from bigbluebutton-web/src/java/org/bigbluebutton/api/domain/UserSession.java rename to bbb-common-web/src/main/java/org/bigbluebutton/api/domain/UserSession.java diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/MeetingResponseDetail.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/MeetingResponseDetail.java new file mode 100755 index 0000000000000000000000000000000000000000..d9ebe5293f0b05c34e4ef72cdd3640ba6d72e8e2 --- /dev/null +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/MeetingResponseDetail.java @@ -0,0 +1,23 @@ +package org.bigbluebutton.api.util; + + +import org.bigbluebutton.api.domain.Meeting; + +public class MeetingResponseDetail { + + private final String createdOn; + private final Meeting meeting; + + public MeetingResponseDetail(String createdOn, Meeting meeting) { + this.createdOn = createdOn; + this.meeting = meeting; + } + + public String getCreatedOn() { + return createdOn; + } + + public Meeting getMeeting() { + return meeting; + } +} diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/MeetingsResponse.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/MeetingsResponse.java new file mode 100755 index 0000000000000000000000000000000000000000..0e67f3eadcf5b42bbc5e31500fdcf9739cd6a24f --- /dev/null +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/MeetingsResponse.java @@ -0,0 +1,12 @@ +package org.bigbluebutton.api.util; + +import java.util.Collection; + +public class MeetingsResponse { + + public final Collection<MeetingResponseDetail> meetings; + + public MeetingsResponse(Collection<MeetingResponseDetail> meetings) { + this.meetings = meetings; + } +} diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/ResponseBuilder.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/ResponseBuilder.java new file mode 100755 index 0000000000000000000000000000000000000000..86685d86fdbfe7ed6bbb51fdcf0f48f1f4abf36e --- /dev/null +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/ResponseBuilder.java @@ -0,0 +1,97 @@ +package org.bigbluebutton.api.util; + +import org.bigbluebutton.api.domain.Meeting; +import org.apache.commons.lang.StringUtils; + +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.util.*; + +import freemarker.template.*; + +public class ResponseBuilder { + Configuration cfg = new Configuration(Configuration.VERSION_2_3_23); + + public ResponseBuilder(File templatesLoc) { + + try { + cfg.setDirectoryForTemplateLoading(templatesLoc); + } catch (IOException e) { + e.printStackTrace(); + } + cfg.setDefaultEncoding("UTF-8"); + cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + cfg.setLogTemplateExceptions(false); + } + + public String formatPrettyDate(Long timestamp) { + return new Date(timestamp).toString(); + } + + public String buildGetMeetingInfoResponse(Meeting meeting, String returnCode) { + String createdOn = formatPrettyDate(meeting.getCreateTime()); + + Template ftl = null; + try { + ftl = cfg.getTemplate("get-meeting-info.ftl"); + } catch (IOException e) { + e.printStackTrace(); + } + + StringWriter xmlText = new StringWriter(); + + Map root = new HashMap(); + root.put("returnCode", returnCode); + root.put("createdOn", createdOn); + root.put("meeting", meeting); + + try { + ftl.process(root, xmlText); + } catch (TemplateException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + + return xmlText.toString(); + } + + public String buildGetMeetingsResponse(Collection<Meeting> meetings, String returnCode) { + + ArrayList<MeetingResponseDetail> meetingResponseDetails = new ArrayList<MeetingResponseDetail>(); + + for (Meeting meeting : meetings) { + String createdOn = formatPrettyDate(meeting.getCreateTime()); + MeetingResponseDetail details = new MeetingResponseDetail(createdOn, meeting); + meetingResponseDetails.add(details); + } + + Template ftl = null; + try { + ftl = cfg.getTemplate("get-meetings.ftl"); + } catch (IOException e) { + e.printStackTrace(); + } + + StringWriter xmlText = new StringWriter(); + + Map root = new HashMap(); + root.put("returnCode", returnCode); + root.put("meetingDetailsList", meetingResponseDetails); + + for (MeetingResponseDetail details : (ArrayList<MeetingResponseDetail>)root.get("meetingDetailsList")) { + System.out.println(details.getMeeting().getName()); + } + + try { + ftl.process(root, xmlText); + } catch (TemplateException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + + return xmlText.toString(); + } +} diff --git a/bbb-common-web/src/test/resources/get-meeting-info.ftl b/bbb-common-web/src/test/resources/get-meeting-info.ftl new file mode 100755 index 0000000000000000000000000000000000000000..c26d16d0e546bb1bdd67d259714fd1a45283cab1 --- /dev/null +++ b/bbb-common-web/src/test/resources/get-meeting-info.ftl @@ -0,0 +1,78 @@ +<#-- GET_RECORDINGS FreeMarker XML template --> +<response> + <#-- Where code is a 'SUCCESS' or 'FAILED' String --> + <returncode>${returnCode}</returncode> + <meetingName>${meeting.getName()}</meetingName> + <meetingID>${meeting.getExternalId()}</meetingID> + <internalMeetingID>${meeting.getInternalId()}</internalMeetingID> + <createTime>${meeting.getCreateTime()?c}</createTime> + <createDate>${createdOn}</createDate> + <voiceBridge>${meeting.getTelVoice()}</voiceBridge> + <dialNumber>${meeting.getDialNumber()}</dialNumber> + <attendeePW>${meeting.getViewerPassword()}</attendeePW> + <moderatorPW>${meeting.getModeratorPassword()}</moderatorPW> + <running>${meeting.isRunning()?c}</running> + <duration>${meeting.getDuration()}</duration> + <hasUserJoined>${meeting.hasUserJoined()?c}</hasUserJoined> + <recording>${meeting.isRecord()?c}</recording> + <hasBeenForciblyEnded>${meeting.isForciblyEnded()?c}</hasBeenForciblyEnded> + <startTime>${meeting.getStartTime()?c}</startTime> + <endTime>${meeting.getEndTime()}</endTime> + <participantCount>${meeting.getNumUsers()}</participantCount> + <listenerCount>${meeting.getNumListenOnly()}</listenerCount> + <voiceParticipantCount>${meeting.getNumVoiceJoined()}</voiceParticipantCount> + <videoCount>${meeting.getNumVideos()}</videoCount> + <maxUsers>${meeting.getMaxUsers()}</maxUsers> + <moderatorCount>${meeting.getNumModerators()}</moderatorCount> + <attendees> + <#list meeting.getUsers() as att> + <attendee> + <userID>${att.getInternalUserId()}</userID> + <fullName>${att.getFullname()}</fullName> + <role>${att.getRole()}</role> + <isPresenter>${att.isPresenter()?c}</isPresenter> + <isListeningOnly>${att.isListeningOnly()?c}</isListeningOnly> + <hasJoinedVoice>${att.isVoiceJoined()?c}</hasJoinedVoice> + <hasVideo>${att.hasVideo()?c}</hasVideo> + <#if meeting.getUserCustomData(att.getExternalUserId())??> + <#assign ucd = meeting.getUserCustomData(att.getExternalUserId())> + <customdata> + <#list ucd?keys as prop> + <${prop}><![CDATA[${ucd[prop]}]]></${prop}> + </#list> + </customdata> + </#if> + </attendee> + </#list> + </attendees> + <#assign m = meeting.getMetadata()> + <metadata> + <#list m?keys as prop> + <${prop}><![CDATA[${m[prop]}]]></${prop}> + </#list> + </metadata> + + <#if messageKey?has_content> + <messageKey>${messageKey}</messageKey> + </#if> + + <#if message?has_content> + <message>${message}</message> + </#if> + + <#if meeting.isBreakout()> + <breakout> + <parentMeetingID>${meeting.getParentMeetingId()}</parentMeetingID> + <sequence>${meeting.getSequence()}</sequence> + </breakout> + </#if> + + <#list meeting.getBreakoutRooms()> + <breakoutRooms> + <#items as room> + <breakout>${room}</breakout> + </#items> + </breakoutRooms> + </#list> + +</response> \ No newline at end of file diff --git a/bbb-common-web/src/test/resources/get-meetings.ftl b/bbb-common-web/src/test/resources/get-meetings.ftl new file mode 100755 index 0000000000000000000000000000000000000000..fdee481dbdfec7dfc888a966497184a005d1e0d5 --- /dev/null +++ b/bbb-common-web/src/test/resources/get-meetings.ftl @@ -0,0 +1,78 @@ +<#-- GET_RECORDINGS FreeMarker XML template --> +<response> + <#-- Where code is a 'SUCCESS' or 'FAILED' String --> + <returncode>${returnCode}</returncode> + <#list meetingDetailsList> + <meetings> + <#items as meetingDetail> + <#assign meeting = meetingDetail.getMeeting()> + <meeting> + <meetingName>${meeting.getName()}</meetingName> + <meetingID>${meeting.getExternalId()}</meetingID> + <internalMeetingID>${meeting.getInternalId()}</internalMeetingID> + <createTime>${meeting.getCreateTime()?c}</createTime> + <createDate>${meetingDetail.getCreatedOn()}</createDate> + <voiceBridge>${meeting.getTelVoice()}</voiceBridge> + <dialNumber>${meeting.getDialNumber()}</dialNumber> + <attendeePW>${meeting.getViewerPassword()}</attendeePW> + <moderatorPW>${meeting.getModeratorPassword()}</moderatorPW> + <running>${meeting.isRunning()?c}</running> + <duration>${meeting.getDuration()}</duration> + <hasUserJoined>${meeting.hasUserJoined()?c}</hasUserJoined> + <recording>${meeting.isRecord()?c}</recording> + <hasBeenForciblyEnded>${meeting.isForciblyEnded()?c}</hasBeenForciblyEnded> + <startTime>${meeting.getStartTime()?c}</startTime> + <endTime>${meeting.getEndTime()}</endTime> + <participantCount>${meeting.getNumUsers()}</participantCount> + <listenerCount>${meeting.getNumListenOnly()}</listenerCount> + <voiceParticipantCount>${meeting.getNumVoiceJoined()}</voiceParticipantCount> + <videoCount>${meeting.getNumVideos()}</videoCount> + <maxUsers>${meeting.getMaxUsers()}</maxUsers> + <moderatorCount>${meeting.getNumModerators()}</moderatorCount> + <attendees> + <#list meetingDetail.meeting.getUsers() as att> + <attendee> + <userID>${att.getInternalUserId()}</userID> + <fullName>${att.getFullname()}</fullName> + <role>${att.getRole()}</role> + <isPresenter>${att.isPresenter()?c}</isPresenter> + <isListeningOnly>${att.isListeningOnly()?c}</isListeningOnly> + <hasJoinedVoice>${att.isVoiceJoined()?c}</hasJoinedVoice> + <hasVideo>${att.hasVideo()?c}</hasVideo> + <#if meeting.getUserCustomData(att.getExternalUserId())??> + <#assign ucd = meetingDetail.meeting.getUserCustomData(att.getExternalUserId())> + <customdata> + <#list ucd?keys as prop> + <${prop}><![CDATA[${ucd[prop]}]]></${prop}> + </#list> + </customdata> + </#if> + </attendee> + </#list> + </attendees> + <#assign m = meetingDetail.meeting.getMetadata()> + <metadata> + <#list m?keys as prop> + <${prop}><![CDATA[${m[prop]}]]></${prop}> + </#list> + </metadata> + + <#if meetingDetail.meeting.isBreakout()> + <breakout> + <parentMeetingID>${meetingDetail.meeting.getParentMeetingId()}</parentMeetingID> + <sequence>${meetingDetail.meeting.getSequence()}</sequence> + </breakout> + </#if> + + <#list meetingDetail.meeting.getBreakoutRooms()> + <breakoutRooms> + <#items as room> + <breakout>${room}</breakout> + </#items> + </breakoutRooms> + </#list> + </meeting> + </#items> + </meetings> + </#list> +</response> \ No newline at end of file diff --git a/bbb-common-web/src/test/scala/org/bigbluebutton/api/util/ResponseBuilderTest.scala b/bbb-common-web/src/test/scala/org/bigbluebutton/api/util/ResponseBuilderTest.scala new file mode 100755 index 0000000000000000000000000000000000000000..b8a786f4ae700abae35a97435b7e7c89d72b8aeb --- /dev/null +++ b/bbb-common-web/src/test/scala/org/bigbluebutton/api/util/ResponseBuilderTest.scala @@ -0,0 +1,185 @@ +package org.bigbluebutton.api.util + +import java.io.File +import java.util + +import org.bigbluebutton.api.domain.{Meeting, User} +import org.scalatest._ + +class ResponseBuilderTest extends UnitSpec { + + it should "find template" in { + val current = new java.io.File( "." ).getCanonicalPath() + println("Current dir:"+current) + + + + val meetingInfo = new util.TreeMap[String, String]() + meetingInfo.put("foo", "foo") + meetingInfo.put("bar", "baz") + + val meeting: Meeting = new Meeting.Builder("extMid", "intMid", System.currentTimeMillis()) + .withName("Demo Meeting").withMaxUsers(25) + .withModeratorPass("mp").withViewerPass("ap") + .withRecording(true).withDuration(600) + .withLogoutUrl("/logoutUrl").withTelVoice("85115").withWebVoice("85115") + .withDialNumber("6135551234").withDefaultAvatarURL("/avatar") + .withAutoStartRecording(false).withAllowStartStopRecording(true) + .withWebcamsOnlyForModerator(false).withMetadata(meetingInfo) + .withWelcomeMessageTemplate("hello").withWelcomeMessage("hello") + .isBreakout(false).build + + meeting.setParentMeetingId("ParentMeetingId") + meeting.setSequence(0); + + meeting.addBreakoutRoom("breakout-room-id-1") + meeting.addBreakoutRoom("breakout-room-id-2") + meeting.addBreakoutRoom("breakout-room-id-3") + + val user: User = new User("uid1", "extuid1", "Richard", "moderator", "/aygwapo") + meeting.userJoined(user) + + val user2: User = new User("uid2", "extuid2", "Richard 2", "moderator", "/aygwapo") + meeting.userJoined(user2) + + val user3: User = new User("uid3", "extuid3", "Richard 3", "moderator", "/aygwapo") + meeting.userJoined(user2) + + val custData = new util.HashMap[String, String]() + custData.put("gwapo", "totoo") + + meeting.addUserCustomData("extuid1", custData) + + val templateLoc = new File("src/test/resources") + val builder = new ResponseBuilder(templateLoc) + def response = builder.buildGetMeetingInfoResponse(meeting, "success") + println(response) + + assert(templateLoc.exists()) + } + + it should "return meetings" in { + val meetingInfo1 = new util.TreeMap[String, String]() + meetingInfo1.put("foo", "foo") + meetingInfo1.put("bar", "baz") + + val meeting1: Meeting = new Meeting.Builder("extMid1", "intMid1", System.currentTimeMillis()) + .withName("Demo Meeting 1").withMaxUsers(25) + .withModeratorPass("mp").withViewerPass("ap") + .withRecording(true).withDuration(600) + .withLogoutUrl("/logoutUrl").withTelVoice("85115").withWebVoice("85115") + .withDialNumber("6135551234").withDefaultAvatarURL("/avatar") + .withAutoStartRecording(false).withAllowStartStopRecording(true) + .withWebcamsOnlyForModerator(false).withMetadata(meetingInfo1) + .withWelcomeMessageTemplate("hello").withWelcomeMessage("hello") + .isBreakout(false).build + + meeting1.setParentMeetingId("ParentMeetingId") + meeting1.setSequence(0); + + meeting1.addBreakoutRoom("breakout-room-id-1") + meeting1.addBreakoutRoom("breakout-room-id-2") + meeting1.addBreakoutRoom("breakout-room-id-3") + + val userm11: User = new User("uid1", "extuid1", "Richard", "moderator", "/aygwapo") + meeting1.userJoined(userm11) + + val userm12: User = new User("uid2", "extuid2", "Richard 2", "moderator", "/aygwapo") + meeting1.userJoined(userm12) + + val userm13: User = new User("uid3", "extuid3", "Richard 3", "moderator", "/aygwapo") + meeting1.userJoined(userm13) + + val custDatam1 = new util.HashMap[String, String]() + custDatam1.put("gwapo", "totoo") + + meeting1.addUserCustomData("extuid1", custDatam1) + + val meetingInfo2 = new util.TreeMap[String, String]() + meetingInfo2.put("foo", "foo") + meetingInfo2.put("bar", "baz") + + val meeting2: Meeting = new Meeting.Builder("extMid2", "intMid2", System.currentTimeMillis()) + .withName("Demo Meeting 2").withMaxUsers(25) + .withModeratorPass("mp").withViewerPass("ap") + .withRecording(true).withDuration(600) + .withLogoutUrl("/logoutUrl").withTelVoice("85115").withWebVoice("85115") + .withDialNumber("6135551234").withDefaultAvatarURL("/avatar") + .withAutoStartRecording(false).withAllowStartStopRecording(true) + .withWebcamsOnlyForModerator(false).withMetadata(meetingInfo2) + .withWelcomeMessageTemplate("hello").withWelcomeMessage("hello") + .isBreakout(false).build + + meeting2.setParentMeetingId("ParentMeetingId") + meeting2.setSequence(0); + + meeting2.addBreakoutRoom("breakout-room-id-1") + meeting2.addBreakoutRoom("breakout-room-id-2") + meeting2.addBreakoutRoom("breakout-room-id-3") + + val userm21: User = new User("uid1", "extuid1", "Richard", "moderator", "/aygwapo") + meeting2.userJoined(userm21) + + val userm22: User = new User("uid2", "extuid2", "Richard 2", "moderator", "/aygwapo") + meeting2.userJoined(userm22) + + val userm23: User = new User("uid3", "extuid3", "Richard 3", "moderator", "/aygwapo") + meeting2.userJoined(userm23) + + val custDatam2 = new util.HashMap[String, String]() + custDatam2.put("gwapo", "totoo") + + meeting2.addUserCustomData("extuid1", custDatam2) + + + val meetingInfo3 = new util.TreeMap[String, String]() + meetingInfo3.put("foo", "foo") + meetingInfo3.put("bar", "baz") + + val meeting3: Meeting = new Meeting.Builder("extMid", "intMid", System.currentTimeMillis()) + .withName("Demo Meeting").withMaxUsers(25) + .withModeratorPass("mp").withViewerPass("ap") + .withRecording(true).withDuration(600) + .withLogoutUrl("/logoutUrl").withTelVoice("85115").withWebVoice("85115") + .withDialNumber("6135551234").withDefaultAvatarURL("/avatar") + .withAutoStartRecording(false).withAllowStartStopRecording(true) + .withWebcamsOnlyForModerator(false).withMetadata(meetingInfo3) + .withWelcomeMessageTemplate("hello").withWelcomeMessage("hello") + .isBreakout(false).build + + meeting3.setParentMeetingId("ParentMeetingId") + meeting3.setSequence(0); + + meeting3.addBreakoutRoom("breakout-room-id-1") + meeting3.addBreakoutRoom("breakout-room-id-2") + meeting3.addBreakoutRoom("breakout-room-id-3") + + val user: User = new User("uid1", "extuid1", "Richard", "moderator", "/aygwapo") + meeting3.userJoined(user) + + val user2: User = new User("uid2", "extuid2", "Richard 2", "moderator", "/aygwapo") + meeting3.userJoined(user2) + + val user3: User = new User("uid3", "extuid3", "Richard 3", "moderator", "/aygwapo") + meeting3.userJoined(user2) + + val custData = new util.HashMap[String, String]() + custData.put("gwapo", "totoo") + + meeting3.addUserCustomData("extuid1", custData) + + + + val meetings = new util.ArrayList[Meeting]() + meetings.add(meeting1) + meetings.add(meeting2) + meetings.add(meeting3) + + val templateLoc = new File("src/test/resources") + val builder = new ResponseBuilder(templateLoc) + def response = builder.buildGetMeetingsResponse(meetings, "success") + println(response) + + assert(templateLoc.exists()) + } +} \ No newline at end of file diff --git a/bbb-common-web/src/test/scala/org/bigbluebutton/api/util/UnitSpec.scala b/bbb-common-web/src/test/scala/org/bigbluebutton/api/util/UnitSpec.scala new file mode 100755 index 0000000000000000000000000000000000000000..fef2d5a231b50c2ea1597f488d4533cd972b040c --- /dev/null +++ b/bbb-common-web/src/test/scala/org/bigbluebutton/api/util/UnitSpec.scala @@ -0,0 +1,8 @@ +package org.bigbluebutton.api.util + +import org.scalatest.FlatSpec +import org.scalatest.BeforeAndAfterAll +import org.scalatest.WordSpec +import org.scalatest.Matchers + +abstract class UnitSpec extends FlatSpec with Matchers with BeforeAndAfterAll diff --git a/bigbluebutton-web/build.gradle b/bigbluebutton-web/build.gradle index ce6c71534b855548c196e9dcc4fd72fbdbb20c7b..9ada92023833c731967f4c770964117beddd5222 100755 --- a/bigbluebutton-web/build.gradle +++ b/bigbluebutton-web/build.gradle @@ -26,6 +26,7 @@ dependencies { compile 'com.zaxxer:nuprocess:1.1.0' compile 'org.bigbluebutton:bbb-common-message:0.0.18-SNAPSHOT' + compile 'org.bigbluebutton:bbb-common-web:0.0.1-SNAPSHOT' // Logging // Commenting out as it results in build failure (ralam - may 11, 2014) diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy index 5cf6e37d548c17e740eec8c50211fb9d8490af14..50e6a46615deca689f0f51f6b891efb95f76a284 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy @@ -19,6 +19,7 @@ package org.bigbluebutton.web.controllers import com.google.gson.Gson +import org.bigbluebutton.api.util.ResponseBuilder import javax.servlet.ServletRequest; @@ -53,9 +54,10 @@ import org.bigbluebutton.web.services.turn.StunTurnService; import org.bigbluebutton.web.services.turn.TurnEntry; import org.json.JSONArray; import org.json.JSONObject; - +import org.bigbluebutton.api.util.ResponseBuilder import freemarker.template.Configuration; import freemarker.cache.WebappTemplateLoader; +import java.io.File; class ApiController { private static final Integer SESSION_TIMEOUT = 14400 // 4 hours @@ -75,6 +77,8 @@ class ApiController { PresentationUrlDownloadService presDownloadService StunTurnService stunTurnService + + /* general methods */ def index = { log.debug CONTROLLER_NAME + "#index" @@ -791,7 +795,15 @@ class ApiController { return; } - respondWithConferenceDetails(meeting, null, null, null); + def templateLoc = getServletContext().getRealPath("/WEB-INF/freemarker") + ResponseBuilder responseBuilder = new ResponseBuilder(new File(templateLoc)) + + def xmlText = responseBuilder.buildGetMeetingInfoResponse(meeting, "success") + withFormat { + xml { + render(text: xmlText, contentType: "text/xml") + } + } } /************************************ @@ -850,41 +862,14 @@ class ApiController { } } else { response.addHeader("Cache-Control", "no-cache") + + def templateLoc = getServletContext().getRealPath("/WEB-INF/freemarker") + ResponseBuilder responseBuilder = new ResponseBuilder(new File(templateLoc)) + + def xmlText = responseBuilder.buildGetMeetingsResponse(mtgs, "success") withFormat { xml { - render(contentType:"text/xml") { - response() { - returncode(RESP_CODE_SUCCESS) - meetings { - for (m in mtgs) { - meeting { - meetingID() { mkp.yield(m.getExternalId()) } - internalMeetingID() { mkp.yield(m.getInternalId()) } - if (m.isBreakout()) { - parentMeetingID() { mkp.yield(m.getParentMeetingId()) } - sequence(m.getSequence()) - } - isBreakout() { mkp.yield(m.isBreakout()) } - meetingName() { mkp.yield(m.getName()) } - createTime(m.getCreateTime()) - createDate(formatPrettyDate(m.getCreateTime())) - voiceBridge() { mkp.yield(m.getTelVoice()) } - dialNumber() { mkp.yield(m.getDialNumber()) } - attendeePW() { mkp.yield(m.getViewerPassword()) } - moderatorPW() { mkp.yield(m.getModeratorPassword()) } - hasBeenForciblyEnded(m.isForciblyEnded() ? "true" : "false") - running(m.isRunning() ? "true" : "false") - participantCount(m.getNumUsers()) - listenerCount(m.getNumListenOnly()) - voiceParticipantCount(m.getNumVoiceJoined()) - videoCount(m.getNumVideos()) - duration(m.duration) - hasUserJoined(m.hasUserJoined()) - } - } - } - } - } + render(text: xmlText, contentType: "text/xml") } } } @@ -1798,6 +1783,9 @@ class ApiController { } return; } + + + def cfg = new Configuration() // Load the XML template diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/MeetingService.java b/bigbluebutton-web/src/java/org/bigbluebutton/api/MeetingService.java index 2d4f22553d2300c101783a50b97b9c87de263053..996417b5b4ccf811b24c6b60cb09868cc8b720a4 100755 --- a/bigbluebutton-web/src/java/org/bigbluebutton/api/MeetingService.java +++ b/bigbluebutton-web/src/java/org/bigbluebutton/api/MeetingService.java @@ -302,6 +302,19 @@ public class MeetingService implements MessageListener { } private void handleCreateMeeting(Meeting m) { + Map<String, String> breakoutMetadata = new TreeMap<String, String>(); + + if (m.isBreakout()){ + breakoutMetadata.put("meetingId", m.getExternalId()); + breakoutMetadata.put("sequence", m.getSequence().toString()); + breakoutMetadata.put("parentMeetingId", m.getParentMeetingId()); + Meeting parent = meetings.get(m.getParentMeetingId()); + parent.addBreakoutRoom(m.getExternalId()); + if (parent.isRecord()) { + messagingService.addBreakoutRoom(parent.getInternalId(), m.getInternalId()); + } + } + if (m.isRecord()) { Map<String, String> metadata = new TreeMap<String, String>(); metadata.putAll(m.getMetadata()); @@ -310,12 +323,6 @@ public class MeetingService implements MessageListener { metadata.put("meetingName", m.getName()); metadata.put("isBreakout", m.isBreakout().toString()); - Map<String, String> breakoutMetadata = new TreeMap<String, String>(); - breakoutMetadata.put("meetingId", m.getExternalId()); - if (m.isBreakout()){ - breakoutMetadata.put("sequence", m.getSequence().toString()); - breakoutMetadata.put("parentMeetingId", m.getParentMeetingId()); - } messagingService.recordMeetingInfo(m.getInternalId(), metadata, breakoutMetadata); } diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/MessagingService.java b/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/MessagingService.java index 70974ae0b4fe440e520d89b670d0596487e9f0ee..7e57ba4bbc9e81382b8b0e02c198c41edbebfd49 100755 --- a/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/MessagingService.java +++ b/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/MessagingService.java @@ -28,6 +28,7 @@ import java.util.Set; public interface MessagingService { void recordMeetingInfo(String meetingId, Map<String, String> info, Map<String, String> breakoutInfo); + void addBreakoutRoom(String parentId, String breakoutId); void destroyMeeting(String meetingID); void createMeeting(String meetingID, String externalMeetingID, String parentMeetingID, String meetingName, Boolean recorded, diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/RedisMessagingService.java b/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/RedisMessagingService.java index 768be2a43ff9cb31ab061435b75df458620565ac..9909890f26846ad6b001fe2d7c76e8805ec33990 100755 --- a/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/RedisMessagingService.java +++ b/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/RedisMessagingService.java @@ -53,6 +53,10 @@ public class RedisMessagingService implements MessagingService { storeService.recordMeetingInfo(meetingId, info, breakoutInfo); } + public void addBreakoutRoom(String parentId, String breakoutId) { + storeService.addBreakoutRoom(parentId, breakoutId); + } + public void destroyMeeting(String meetingID) { DestroyMeetingMessage msg = new DestroyMeetingMessage(meetingID); String json = MessageToJson.destroyMeetingMessageToJson(msg); diff --git a/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/RedisStorageService.java b/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/RedisStorageService.java index f27a34d9b928dfee9f95f22d1e0935f1de55b33d..f458ed4eaadadd13866436e999e1a7ffac44a1c9 100755 --- a/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/RedisStorageService.java +++ b/bigbluebutton-web/src/java/org/bigbluebutton/api/messaging/RedisStorageService.java @@ -54,6 +54,19 @@ public class RedisStorageService { jedis.close(); } } + + public void addBreakoutRoom(String parentId, String breakoutId) { + Jedis jedis = redisPool.getResource(); + try { + + log.debug("Saving breakout room for meeting {}", parentId); + jedis.sadd("meeting:breakout:rooms:" + parentId, breakoutId); + } catch (Exception e) { + log.warn("Cannot record the info meeting:" + parentId, e); + } finally { + jedis.close(); + } + } public void removeMeeting(String meetingId){ Jedis jedis = redisPool.getResource(); diff --git a/bigbluebutton-web/web-app/WEB-INF/freemarker/get-meeting-info.ftl b/bigbluebutton-web/web-app/WEB-INF/freemarker/get-meeting-info.ftl new file mode 100755 index 0000000000000000000000000000000000000000..c26d16d0e546bb1bdd67d259714fd1a45283cab1 --- /dev/null +++ b/bigbluebutton-web/web-app/WEB-INF/freemarker/get-meeting-info.ftl @@ -0,0 +1,78 @@ +<#-- GET_RECORDINGS FreeMarker XML template --> +<response> + <#-- Where code is a 'SUCCESS' or 'FAILED' String --> + <returncode>${returnCode}</returncode> + <meetingName>${meeting.getName()}</meetingName> + <meetingID>${meeting.getExternalId()}</meetingID> + <internalMeetingID>${meeting.getInternalId()}</internalMeetingID> + <createTime>${meeting.getCreateTime()?c}</createTime> + <createDate>${createdOn}</createDate> + <voiceBridge>${meeting.getTelVoice()}</voiceBridge> + <dialNumber>${meeting.getDialNumber()}</dialNumber> + <attendeePW>${meeting.getViewerPassword()}</attendeePW> + <moderatorPW>${meeting.getModeratorPassword()}</moderatorPW> + <running>${meeting.isRunning()?c}</running> + <duration>${meeting.getDuration()}</duration> + <hasUserJoined>${meeting.hasUserJoined()?c}</hasUserJoined> + <recording>${meeting.isRecord()?c}</recording> + <hasBeenForciblyEnded>${meeting.isForciblyEnded()?c}</hasBeenForciblyEnded> + <startTime>${meeting.getStartTime()?c}</startTime> + <endTime>${meeting.getEndTime()}</endTime> + <participantCount>${meeting.getNumUsers()}</participantCount> + <listenerCount>${meeting.getNumListenOnly()}</listenerCount> + <voiceParticipantCount>${meeting.getNumVoiceJoined()}</voiceParticipantCount> + <videoCount>${meeting.getNumVideos()}</videoCount> + <maxUsers>${meeting.getMaxUsers()}</maxUsers> + <moderatorCount>${meeting.getNumModerators()}</moderatorCount> + <attendees> + <#list meeting.getUsers() as att> + <attendee> + <userID>${att.getInternalUserId()}</userID> + <fullName>${att.getFullname()}</fullName> + <role>${att.getRole()}</role> + <isPresenter>${att.isPresenter()?c}</isPresenter> + <isListeningOnly>${att.isListeningOnly()?c}</isListeningOnly> + <hasJoinedVoice>${att.isVoiceJoined()?c}</hasJoinedVoice> + <hasVideo>${att.hasVideo()?c}</hasVideo> + <#if meeting.getUserCustomData(att.getExternalUserId())??> + <#assign ucd = meeting.getUserCustomData(att.getExternalUserId())> + <customdata> + <#list ucd?keys as prop> + <${prop}><![CDATA[${ucd[prop]}]]></${prop}> + </#list> + </customdata> + </#if> + </attendee> + </#list> + </attendees> + <#assign m = meeting.getMetadata()> + <metadata> + <#list m?keys as prop> + <${prop}><![CDATA[${m[prop]}]]></${prop}> + </#list> + </metadata> + + <#if messageKey?has_content> + <messageKey>${messageKey}</messageKey> + </#if> + + <#if message?has_content> + <message>${message}</message> + </#if> + + <#if meeting.isBreakout()> + <breakout> + <parentMeetingID>${meeting.getParentMeetingId()}</parentMeetingID> + <sequence>${meeting.getSequence()}</sequence> + </breakout> + </#if> + + <#list meeting.getBreakoutRooms()> + <breakoutRooms> + <#items as room> + <breakout>${room}</breakout> + </#items> + </breakoutRooms> + </#list> + +</response> \ No newline at end of file diff --git a/bigbluebutton-web/web-app/WEB-INF/freemarker/get-meetings.ftl b/bigbluebutton-web/web-app/WEB-INF/freemarker/get-meetings.ftl new file mode 100755 index 0000000000000000000000000000000000000000..fdee481dbdfec7dfc888a966497184a005d1e0d5 --- /dev/null +++ b/bigbluebutton-web/web-app/WEB-INF/freemarker/get-meetings.ftl @@ -0,0 +1,78 @@ +<#-- GET_RECORDINGS FreeMarker XML template --> +<response> + <#-- Where code is a 'SUCCESS' or 'FAILED' String --> + <returncode>${returnCode}</returncode> + <#list meetingDetailsList> + <meetings> + <#items as meetingDetail> + <#assign meeting = meetingDetail.getMeeting()> + <meeting> + <meetingName>${meeting.getName()}</meetingName> + <meetingID>${meeting.getExternalId()}</meetingID> + <internalMeetingID>${meeting.getInternalId()}</internalMeetingID> + <createTime>${meeting.getCreateTime()?c}</createTime> + <createDate>${meetingDetail.getCreatedOn()}</createDate> + <voiceBridge>${meeting.getTelVoice()}</voiceBridge> + <dialNumber>${meeting.getDialNumber()}</dialNumber> + <attendeePW>${meeting.getViewerPassword()}</attendeePW> + <moderatorPW>${meeting.getModeratorPassword()}</moderatorPW> + <running>${meeting.isRunning()?c}</running> + <duration>${meeting.getDuration()}</duration> + <hasUserJoined>${meeting.hasUserJoined()?c}</hasUserJoined> + <recording>${meeting.isRecord()?c}</recording> + <hasBeenForciblyEnded>${meeting.isForciblyEnded()?c}</hasBeenForciblyEnded> + <startTime>${meeting.getStartTime()?c}</startTime> + <endTime>${meeting.getEndTime()}</endTime> + <participantCount>${meeting.getNumUsers()}</participantCount> + <listenerCount>${meeting.getNumListenOnly()}</listenerCount> + <voiceParticipantCount>${meeting.getNumVoiceJoined()}</voiceParticipantCount> + <videoCount>${meeting.getNumVideos()}</videoCount> + <maxUsers>${meeting.getMaxUsers()}</maxUsers> + <moderatorCount>${meeting.getNumModerators()}</moderatorCount> + <attendees> + <#list meetingDetail.meeting.getUsers() as att> + <attendee> + <userID>${att.getInternalUserId()}</userID> + <fullName>${att.getFullname()}</fullName> + <role>${att.getRole()}</role> + <isPresenter>${att.isPresenter()?c}</isPresenter> + <isListeningOnly>${att.isListeningOnly()?c}</isListeningOnly> + <hasJoinedVoice>${att.isVoiceJoined()?c}</hasJoinedVoice> + <hasVideo>${att.hasVideo()?c}</hasVideo> + <#if meeting.getUserCustomData(att.getExternalUserId())??> + <#assign ucd = meetingDetail.meeting.getUserCustomData(att.getExternalUserId())> + <customdata> + <#list ucd?keys as prop> + <${prop}><![CDATA[${ucd[prop]}]]></${prop}> + </#list> + </customdata> + </#if> + </attendee> + </#list> + </attendees> + <#assign m = meetingDetail.meeting.getMetadata()> + <metadata> + <#list m?keys as prop> + <${prop}><![CDATA[${m[prop]}]]></${prop}> + </#list> + </metadata> + + <#if meetingDetail.meeting.isBreakout()> + <breakout> + <parentMeetingID>${meetingDetail.meeting.getParentMeetingId()}</parentMeetingID> + <sequence>${meetingDetail.meeting.getSequence()}</sequence> + </breakout> + </#if> + + <#list meetingDetail.meeting.getBreakoutRooms()> + <breakoutRooms> + <#items as room> + <breakout>${room}</breakout> + </#items> + </breakoutRooms> + </#list> + </meeting> + </#items> + </meetings> + </#list> +</response> \ No newline at end of file