diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcac78b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/secret/*.txt +/data/*.xml \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..73f69e0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/bbb-status.iml b/.idea/bbb-status.iml new file mode 100644 index 0000000..371977c --- /dev/null +++ b/.idea/bbb-status.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..9727bbc --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5989892 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed59cdb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.8 + +WORKDIR /usr/src/ + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY ./src/ ./ + +CMD [ "python", "./logMeetingData.py" ] \ No newline at end of file diff --git a/README.md b/README.md index e69de29..183eb0d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,9 @@ +# README + +Execute `bbb-conf --secret` on your server and place your + +This gives you your **API-secret** as well as a link to **API-Mate** wheren +you can test new requests with your secret prefilled. + +* URL inside `secrets/url.txt` +* secret inside `secret/secret.txt` diff --git a/data/dummy b/data/dummy new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..55164f8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.7' +services: + dk-gen: + build: . + environment: + - PYTHONPATH=/usr/src/ + volumes: +# - ./src/:/usr/src/ + - ./secret:/usr/secret + - ./data:/usr/data \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eed6988 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests>=2.25.0 \ No newline at end of file diff --git a/secret/dummy b/secret/dummy new file mode 100644 index 0000000..e69de29 diff --git a/src/bbbRequest.py b/src/bbbRequest.py new file mode 100644 index 0000000..5490b0a --- /dev/null +++ b/src/bbbRequest.py @@ -0,0 +1,48 @@ +import hashlib +import requests +from xml.etree import ElementTree + +import util + + +def requestMeetingData() -> str: + response = requests.get(getRequestUrl()) + if not response.ok: + raise ValueError("error during request, got status code {}".format(response.status_code)) + + tree = ElementTree.fromstring(response.content) + if tree.find('returncode').text != 'SUCCESS': + raise ValueError('error getting API data') + + return str(response.content, encoding=response.encoding) + + +def getRequestUrl(api_method: str = 'getMeetings', query_string: str = '') -> str: + url = getUrl() + api_url = url + "api/" + secret = getSecret() + h = hashlib.sha1((api_method + query_string + secret).encode('utf-8')) + checksum = h.hexdigest() + + if len(query_string) > 0: + query_string += '&' + + return api_url + api_method + '?' + query_string + 'checksum=' + checksum + + +def getUrl() -> str: + filepath = util.getWorkingDir().joinpath("../secret").joinpath("url.txt") + url = util.readFirstLine(filepath).strip() + if not url.endswith("/"): + raise ValueError("url should end with '/'") + return url + + +def getSecret() -> str: + filepath = util.getWorkingDir().joinpath("../secret").joinpath("secret.txt") + secret = util.readFirstLine(filepath).strip() + min_length = 12 + if len(secret) <= min_length: + raise ValueError("secret should be longer than {} characters!".format(min_length)) + return secret + diff --git a/src/logMeetingData.py b/src/logMeetingData.py new file mode 100755 index 0000000..f1a6da5 --- /dev/null +++ b/src/logMeetingData.py @@ -0,0 +1,47 @@ +#!/usr/bin/python3 + +import time + +import saveMeetings +import bbbRequest +import parseMeetings +import util + + +def sleepFiveMin(verbose=False): + minute = 60 + fiveMinutes = 5 * minute + + if verbose: + print("> Sleeping for five minutes ...") + time.sleep(fiveMinutes) + + +def v2(): + print("BBB meetingData logger started!") + + while True: + meetingsStr = bbbRequest.requestMeetingData() + saveDir = util.getWorkingDir().joinpath("../data") + savedFile = saveMeetings.saveMeetingsData(meetingsStr, saveDir) + print("Saved meetings at {}".format(savedFile)) + + meetings = parseMeetings.parseMeetingsData(meetingsStr) + bbbStatus = parseMeetings.BbbStatus(meetings) + bbbStatusStr = str(bbbStatus) + print(util.indentMultilineStr(bbbStatusStr)) + + sleepFiveMin(verbose=True) + + +def v1(): + while True: + saveDir = util.getWorkingDir().joinpath("../data") + saveMeetings.requestAndSaveMeetingData(saveDir) + print('.', end='') + sleepFiveMin() + + +if __name__ == '__main__': + v2() + exit(1) diff --git a/src/parseMeetings.py b/src/parseMeetings.py new file mode 100644 index 0000000..b987b49 --- /dev/null +++ b/src/parseMeetings.py @@ -0,0 +1,89 @@ +from typing import List +from xml.etree import ElementTree + +import util + + +class Meeting(object): + def __init__(self, xml_meeting: ElementTree.Element): + self.meetingName: str = xml_meeting.find('meetingName').text + self.meetingID: str = xml_meeting.find('meetingID').text + self.internalMeetingID: str = xml_meeting.find('internalMeetingID').text + + self.isRunning: bool = util.asBoolean(xml_meeting.find('running').text) + self.startTime = xml_meeting.find('startTime').text + if self.isRunning: + self.endTime = None + else: + self.endTime = xml_meeting.find('endTime').text + + # TODO: disable default meeting recordings (!) + self.isRecording: bool = util.asBoolean(xml_meeting.find('recording').text) + self.isBreakout: bool = util.asBoolean(xml_meeting.find('isBreakout').text) + + self.participantCount: int = int(xml_meeting.find('participantCount').text) + self.listenerCount: int = int(xml_meeting.find('listenerCount').text) + self.voiceParticipantCount: int = int(xml_meeting.find('voiceParticipantCount').text) + self.videoCount: int = int(xml_meeting.find('videoCount').text) + self.maxUsers: int = int(xml_meeting.find('maxUsers').text) + self.moderatorCount: int = int(xml_meeting.find('moderatorCount').text) + + self.attendees: List[Attendee] = [Attendee(xml_attendee) for xml_attendee in + xml_meeting.find('attendees').iter('attendee')] + + def __str__(self): + return util.asString(self) + + +class Attendee(object): + def __init__(self, xml_attendee: ElementTree.Element): + # self.userID = xml_attendee.find('userID').text + # self.fullName = xml_attendee.find('fullName').text + self.role = xml_attendee.find('role').text + self.isPresenter: bool = util.asBoolean(xml_attendee.find('isPresenter').text) + self.isListeningOnly: bool = util.asBoolean(xml_attendee.find('isListeningOnly').text) + self.hasJoinedVoice: bool = util.asBoolean(xml_attendee.find('hasJoinedVoice').text) + self.hasVideo: bool = util.asBoolean(xml_attendee.find('hasVideo').text) + + def __str__(self): + return util.asString(self) + + +class BbbStatus(object): + """ + Represents the status of one BBB server at one point of time. + It contains statistics about the running meetings on the server + and (TODO) it's CPU utilization and network usage. + """ + + def __init__(self, meetings: List[Meeting]): + """ + :param meetings: All current meetings + """ + self.meetings = meetings + + self.recordingCount = 0 + self.participantCount = 0 + self.listenerCount = 0 + self.voiceParticipantCount = 0 + self.videoCount = 0 + self.moderatorCount = 0 + + for meeting in meetings: + self.participantCount += meeting.participantCount + self.listenerCount += meeting.listenerCount + self.voiceParticipantCount += meeting.voiceParticipantCount + self.videoCount += meeting.videoCount + self.moderatorCount += meeting.moderatorCount + if meeting.isRecording: + self.recordingCount += 1 + + def __str__(self): + return util.asString(self) + + +def parseMeetingsData(dataStr: str) -> List[Meeting]: + tree = ElementTree.fromstring(dataStr) + xml_meetings = tree.find('meetings') + + return [Meeting(xml_meeting) for xml_meeting in xml_meetings.iter('meeting')] diff --git a/src/saveMeetings.py b/src/saveMeetings.py new file mode 100644 index 0000000..f0e987a --- /dev/null +++ b/src/saveMeetings.py @@ -0,0 +1,48 @@ +from datetime import datetime +from pathlib import Path + +import bbbRequest + + +def requestAndSaveMeetingData(folder: Path) -> Path: + """ + save a new xml file in the given folder + + :param dataStr: + :param folder: + :return: Path to created file + """ + + return saveMeetingsData(bbbRequest.requestMeetingData(), folder) + + +def saveMeetingsData(dataStr: str, folder: Path) -> Path: + """ + save a new xml file in the given folder + + :param dataStr: + :param folder: + :return: Path to created file + """ + + return doSaveData(dataStr, folder, 'meetings') + + +def doSaveData(dataStr: str, folder: Path, dataType: str = 'meetings') -> Path: + """ + save a new xml file in the given folder + + :param dataStr: + :param folder: + :param dataType: e.g. "meetings" for meeting XML data + :return: Path to created file + """ + + # time_str = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + time_str = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = '{}_{}.xml'.format(time_str, dataType) + filepath = folder.joinpath(filename) + + with open(filepath, "w") as xml_file: + xml_file.write(dataStr) + return filepath diff --git a/src/util.py b/src/util.py new file mode 100644 index 0000000..98cdece --- /dev/null +++ b/src/util.py @@ -0,0 +1,42 @@ +from pathlib import Path +import os + +def getWorkingDir() -> Path: + return Path(os.path.dirname(os.path.realpath(__file__))) + +def readFirstLine(file: Path) -> str: + """ + :param file: Path to file + :return: first line of file + """ + with open(file, "r") as f: + return f.readline() + + +def indentMultilineStr(s: str, indentWith='\t'): + indented = indentWith + s.replace('\n', '\n' + indentWith) + if s.endswith('\n'): + indented = indented[:len(indented)-len(indentWith)] + return indented + +def asString(o: object): + attrs = vars(o) # attributes and their values + return '\n'.join("%s: %s" % item for item in attrs.items()) + + +def asBoolean(booleanValue: str): + """ + :param booleanValue: Some boolean value in string representation + :return: the boolean value as boolean object + :raise ValueError: If booleanValue could not be parsed as boolean object + """ + if booleanValue.lower() in ['true', 'yes']: + return True + elif booleanValue.lower() in ['false', 'no']: + return False + else: + raise ValueError("booleanValue (string) could not be parsed to boolean object!") + +# def listAsString(l: list): +# x = ["(", ")"] +# return ", ".join(l).join(x) diff --git a/src/utilTest.py b/src/utilTest.py new file mode 100644 index 0000000..5a1c00b --- /dev/null +++ b/src/utilTest.py @@ -0,0 +1,24 @@ +import unittest +import util + + +class UtilTestCase(unittest.TestCase): + def test_indentString(self): + unindented = "Hello\nWorld!" + expected = "\tHello\n\tWorld!" + + actual = util.indentMultilineStr(unindented) + + self.assertEqual(expected, actual) + + def test_indentString2(self): + unindented = "Hello\nWorld!\n" + expected = "\tHello\n\tWorld!\n" + + actual = util.indentMultilineStr(unindented) + + self.assertEqual(expected, actual) + + +if __name__ == '__main__': + unittest.main()