recorder-mx-py

[MIRROR of https://git.nordgedanken.dev/mtrnord/matrix-call-multitrack-recorder/] A tool to record every participant of a legacy or new matrix, call to separate audio and video files.
git clone git://archive.git.mtrnord.blog/MTRNord/recorder-mx-py.git
Log | Files | Refs | README | LICENSE

commit 9699c77ed5bd457ee502d6a7e9f6c0c94e432eda
parent 617a5075a8e423511802ed7206674d9795ccb44d
Author: MTRNord <mtrnord1@gmail.com>
Date:   Sun, 29 Jan 2023 17:38:09 +0100

Split up code and improve state handling

Diffstat:
Mmatrix_call_multitrack_recorder/__main__.py | 548+------------------------------------------------------------------------------
Amatrix_call_multitrack_recorder/bot.py | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatrix_call_multitrack_recorder/recorder.py | 543+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpyproject.toml | 2+-
4 files changed, 842 insertions(+), 546 deletions(-)

diff --git a/matrix_call_multitrack_recorder/__main__.py b/matrix_call_multitrack_recorder/__main__.py @@ -3,560 +3,18 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio -import getpass -import json -import os -import random -import string -import sys -import time -from typing import Dict, Optional, Tuple -from attr import dataclass -from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack -from aiortc.contrib.media import MediaRecorder, MediaBlackhole -from aiortc.rtcicetransport import candidate_from_aioice -from aioice.candidate import Candidate -from nio import ( - AsyncClient, - LoginResponse, - MatrixRoom, - CallInviteEvent, - CallCandidatesEvent, - CallHangupEvent, - ToDeviceCallInviteEvent, - ToDeviceCallCandidatesEvent, - ToDeviceCallHangupEvent, - AsyncClientConfig, - InviteEvent, - MSC3401CallEvent, - ToDeviceMessage, - to_device, - CallMemberEvent, -) from logbook import Logger, StreamHandler import logbook import sys +from matrix_call_multitrack_recorder.bot import RecordingBot, login + StreamHandler(sys.stdout).push_application() logger = Logger(__name__) logger.level = logbook.INFO -# crypto.logger.level = logbook.DEBUG -to_device.logger.level = logbook.DEBUG - - -RoomID = str -UserID = str -UniqueCallID = Tuple[RoomID, UserID, str] - -# file to store credentials in case you want to run program multiple times -CONFIG_FILE = "credentials.json" # login credentials JSON file -# directory to store persistent data for end-to-end encryption -STORE_PATH = "./store/" # local directory -# directory to store recordings -RECORDING_PATH = "./recordings/" # local directory - - -@dataclass -class WrappedConn: - pc: RTCPeerConnection - prepare_waiter: asyncio.Future - candidate_waiter: asyncio.Future - - -class RecordingBot: - conns: Dict[UniqueCallID, WrappedConn] - party_id: str - client: AsyncClient - loop: asyncio.AbstractEventLoop - - def __init__(self, client) -> None: - self.client = client - self.loop = asyncio.get_event_loop() - - async def start(self) -> None: - self.conns = {} - self.party_id = "".join( - random.choices(string.ascii_letters + string.digits, k=8) - ) - - if not os.path.exists(RECORDING_PATH): - os.makedirs(RECORDING_PATH) - - logger.info("Starting client") - - if self.client.should_upload_keys: - await self.client.keys_upload() - logger.debug("Uploaded keys") - - self.client.add_event_callback(self.call_candidates, CallCandidatesEvent) - self.client.add_event_callback(self.call_invite, CallInviteEvent) - self.client.add_event_callback(self.call_hangup, CallHangupEvent) - self.client.add_event_callback(self.msc3401_call, MSC3401CallEvent) - self.client.add_event_callback(self.msc3401_call_member, CallMemberEvent) - - self.client.add_to_device_callback( - self.to_device_call_candidates, (ToDeviceCallCandidatesEvent,) - ) - self.client.add_to_device_callback( - self.to_device_call_invite, (ToDeviceCallInviteEvent,) - ) - self.client.add_to_device_callback( - self.to_device_call_hangup, (ToDeviceCallHangupEvent,) - ) - - self.client.add_event_callback(self.cb_autojoin_room, InviteEvent) - logger.info("Listening for calls") - - await self.client.sync_forever( - timeout=30000, full_state=True, set_presence="online" - ) - - async def stop(self) -> None: - logger.info("Stopping client") - for (room_id, _, call_id), conn in self.conns.items(): - if room_id.startswith("!"): - hangup = { - "content": { - "call_id": call_id, - "version": 1, - "party_id": self.party_id, - } - } - await self.client.room_send( - room_id, "m.call.hangup", hangup, ignore_unverified_devices=True - ) - await self.client.room_put_state( - room_id, - "org.matrix.msc3401.call.member", - { - "m.calls": [ - { - "m.call_id": call_id, - "m.devices": [], - } - ] - }, - state_key=self.client.user_id, - ) - await conn.pc.close() - await self.client.close() - pass - - # TODO: Negotiate new streams - # TODO: Handle m.call.negotiate to-device events - # TODO: Send m.call.negotiate events with type offer - async def msc3401_call_member( - self, room: MatrixRoom, event: CallMemberEvent - ) -> None: - """Handles incoming MSC3401 call members.""" - logger.info(f"MSC3401 call member event: {event}") - - for call in event.calls: - # TODO: Can I reuse the same connection? Do I have the info needed? Is it a new connection? - pass - # asyncio.create_task(self.handle_call_invite(event, room)) - - async def msc3401_call(self, room: MatrixRoom, event: MSC3401CallEvent) -> None: - """Handles incoming MSC3401 calls.""" - await self.client.room_put_state( - room.room_id, - "org.matrix.msc3401.call.member", - { - "m.calls": [ - { - "m.call_id": event.state_key, - "m.devices": [ - { - "device_id": self.client.device_id, - "expires_ts": int(time.time() * 1000) - + (1000 * 60 * 60), - "session_id": f"{self.client.user_id}_{self.client.device_id}_session", - "feeds": [ - { - "purpose": "m.usermedia", - } - ], - } - ], - } - ] - }, - state_key=self.client.user_id, - ) - - async def handle_call_invite( - self, event: CallInviteEvent, room: Optional[MatrixRoom] - ) -> None: - if room: - logger.info(f"Received call invite from {event.sender} in {room.room_id}") - else: - logger.info(f"Received call invite from {event.sender}") - if event.expired: - logger.warning("Call invite expired") - return - if event.version != "1": - logger.warning("Call invite version not supported") - return - - logger.info("Preparing call") - offer = RTCSessionDescription( - sdp=event.offer.get("sdp"), type=str(event.offer.get("type")) - ) - pc = RTCPeerConnection() - if room: - unique_id = (room.room_id, event.sender, event.call_id) - else: - unique_id = ( - event.source["content"]["conf_id"], - event.sender, - event.call_id, - ) - conn = self.conns[unique_id] = WrappedConn( - pc=pc, - candidate_waiter=self.loop.create_future(), - prepare_waiter=self.loop.create_future(), - ) - logger.info("Adding tracks") - input_tracks = {} - # output_tracks = {"audio": MediaBlackhole(), "video": MediaBlackhole()} - - async def task() -> None: - base_name_audio = f"{event.sender}_{event.call_id}" - base_name_video = f"{event.sender}_{event.call_id}" - if os.path.exists(os.path.join(RECORDING_PATH, f"{base_name_audio}.wav")): - i = 1 - while os.path.exists( - os.path.join(RECORDING_PATH, f"{base_name_audio}_{i}.wav") - ): - i += 1 - base_name_audio = f"{base_name_audio}_{i}" - - if os.path.exists(os.path.join(RECORDING_PATH, f"{base_name_video}.mp4")): - i = 1 - while os.path.exists( - os.path.join(RECORDING_PATH, f"{base_name_video}_{i}.mp4") - ): - i += 1 - base_name_video = f"{base_name_video}_{i}" - - wav_file = os.path.join(RECORDING_PATH, f"{base_name_audio}.wav") - mp4_file = os.path.join(RECORDING_PATH, f"{base_name_video}.mp4") - audio_recorder = MediaRecorder(wav_file, format="wav") - audio_recorder.addTrack(input_tracks["audio"]) - await audio_recorder.start() - if "video" in input_tracks: - video_recorder = MediaRecorder(mp4_file, format="mp4") - video_recorder.addTrack(input_tracks["video"]) - await video_recorder.start() - else: - video_recorder = None - - audio_track = input_tracks["audio"] - if "video" in input_tracks: - video_track = input_tracks["video"] - - @video_track.on("ended") - async def on_ended_video(): - if video_recorder: - await video_recorder.stop() - - @audio_track.on("ended") - async def on_ended_audio(): - await audio_recorder.stop() - - logger.info("Setting up callbacks") - - @pc.on("connectionstatechange") - async def on_connectionstatechange() -> None: - if pc.connectionState == "failed": - await pc.close() - self.conns.pop(unique_id, None) - if pc.connectionState == "connected": - asyncio.create_task(task()) - - @pc.on("track") - def on_track(track: MediaStreamTrack) -> None: - input_tracks[track.kind] = track - - # pc.addTrack(output_tracks[track.kind]) - - logger.info("Waiting for prepare") - await pc.setRemoteDescription(offer) - conn.prepare_waiter.set_result(None) - - if room: - logger.info("Sending receipt") - await self.client.update_receipt_marker(room.room_id, event.event_id) - - logger.info("Waiting for candidates") - await conn.candidate_waiter - - logger.info("Creating answer") - answer = await pc.createAnswer() - await pc.setLocalDescription(answer) - - logger.info("Sending answer") - if room: - answer = { - "call_id": event.call_id, - "version": "1", - "party_id": self.party_id, - "answer": { - # "type": pc.localDescription.type, - "type": "answer", - "sdp": pc.localDescription.sdp, - }, - } - await self.client.room_send( - room.room_id, "m.call.answer", answer, ignore_unverified_devices=True - ) - else: - answer = { - "call_id": event.call_id, - "version": "1", - "party_id": self.party_id, - "conf_id": event.source["content"]["conf_id"], - "capabilities": { - "m.call.transferee": False, - "m.call.dtmf": False, - }, - "answer": { - # "type": pc.localDescription.type, - "type": "answer", - "sdp": pc.localDescription.sdp, - }, - "device_id": self.client.device_id, - "dest_session_id": event.source["content"]["sender_session_id"], - "sender_session_id": f"{self.client.user_id}_{self.client.device_id}_session", - "seq": event.source["content"]["seq"], - } - to_device_message = ToDeviceMessage( - "m.call.answer", - event.source["sender"], - event.source["sender_device"], - answer, - ) - await self.client.to_device(to_device_message) - if room: - logger.info(f"Sent answer to {event.sender} in {room.room_id}") - else: - logger.info( - f"Sent answer to {event.sender} with device {event.source['sender_device']}" - ) - - async def call_invite(self, room: MatrixRoom, event: CallInviteEvent) -> None: - """Handles incoming call invites.""" - - asyncio.create_task(self.handle_call_invite(event, room)) - - async def to_device_call_invite(self, event: CallInviteEvent) -> None: - """Handles incoming call invites.""" - logger.info("Received to-device call invite") - - asyncio.create_task(self.handle_call_invite(event, None)) - - async def handle_call_candidates( - self, room: Optional[MatrixRoom], event: CallCandidatesEvent - ) -> None: - logger.info("Delaying candidates for 3 seconds") - await asyncio.sleep(3) - if room: - logger.info( - f"Received call candidates from {event.sender} in {room.room_id}" - ) - else: - logger.info(f"Received call candidates from {event.sender}") - - if room: - unique_id = (room.room_id, event.sender, event.call_id) - else: - unique_id = ( - event.source["content"]["conf_id"], - event.sender, - event.call_id, - ) - try: - conn = self.conns[unique_id] - except KeyError as e: - logger.warning("Received candidates for unknown call") - return - logger.info("Waiting for prepare") - await conn.prepare_waiter - logger.info("Adding candidates") - for raw_candidate in event.candidates: - if not raw_candidate.get("candidate"): - # End of candidates - conn.candidate_waiter.set_result(None) - break - try: - candidate = candidate_from_aioice( - Candidate.from_sdp(raw_candidate.get("candidate")) - ) - except ValueError as e: - logger.warning("Received invalid candidate: %s", e) - continue - candidate.sdpMid = raw_candidate.get("sdpMid") - candidate.sdpMLineIndex = raw_candidate.get("sdpMLineIndex") - - logger.info(f"Adding candidate {candidate} for {event.call_id}") - await conn.pc.addIceCandidate(candidate) - logger.info("Done adding candidates") - try: - conn.candidate_waiter.set_result(None) - except asyncio.InvalidStateError: - pass - if room: - await self.client.update_receipt_marker(room.room_id, event.event_id) - - async def call_candidates( - self, room: MatrixRoom, event: CallCandidatesEvent - ) -> None: - """Handles incoming call candidates.""" - - asyncio.create_task(self.handle_call_candidates(room, event)) - - async def to_device_call_candidates(self, event: CallCandidatesEvent) -> None: - """Handles incoming call candidates.""" - logger.info("Received to-device call candidates") - - asyncio.create_task(self.handle_call_candidates(None, event)) - - async def handle_call_hangup( - self, - room: Optional[MatrixRoom], - event: CallHangupEvent, - ) -> None: - if room: - logger.info(f"Received call hangup from {event.sender} in {room.room_id}") - try: - if room: - await self.conns.pop( - (room.room_id, event.sender, event.call_id) - ).pc.close() - await self.client.update_receipt_marker(room.room_id, event.event_id) - else: - await self.conns.pop( - ( - event.source["content"]["conf_id"], - event.sender, - event.call_id, - ) - ).pc.close() - - except KeyError: - logger.warning("Received hangup for unknown call") - return - - async def call_hangup(self, room: MatrixRoom, event: CallHangupEvent) -> None: - """Handles call hangups.""" - asyncio.create_task(self.handle_call_hangup(room, event)) - - async def to_device_call_hangup(self, event: CallHangupEvent) -> None: - """Handles call hangups.""" - asyncio.create_task(self.handle_call_hangup(None, event)) - - async def cb_autojoin_room(self, room: MatrixRoom, event: InviteEvent): - """Callback to automatically joins a Matrix room on invite. - - Arguments: - room {MatrixRoom} -- Provided by nio - event {InviteEvent} -- Provided by nio - """ - asyncio.create_task(self.client.join(room.room_id)) - logger.info(f"Joining room {room.room_id} on invite from {event.sender}") - - -def write_details_to_disk(resp: LoginResponse, homeserver) -> None: - """Writes the required login details to disk so we can log in later without - using a password. - - Arguments: - resp {LoginResponse} -- the successful client login response. - homeserver -- URL of homeserver, e.g. "https://matrix.example.org" - """ - # open the config file in write-mode - with open(CONFIG_FILE, "w") as f: - # write the login details to disk - json.dump( - { - "homeserver": homeserver, # e.g. "https://matrix.example.org" - "user_id": resp.user_id, # e.g. "@user:example.org" - "device_id": resp.device_id, # device ID, 10 uppercase letters - "access_token": resp.access_token, # cryptogr. access token - }, - f, - ) - - -async def login() -> AsyncClient: - client_config = AsyncClientConfig( - store_sync_tokens=True, - encryption_enabled=True, - ) - # If there are no previously-saved credentials, we'll use the password - if not os.path.exists(CONFIG_FILE): - logger.info( - "First time use. Did not find credential file. Asking for " - "homeserver, user, and password to create credential file." - ) - homeserver = "https://matrix.midnightthoughts.space" - homeserver = input(f"Enter your homeserver URL: [{homeserver}] ") - - if not (homeserver.startswith("https://") or homeserver.startswith("http://")): - homeserver = "https://" + homeserver - - user_id = "@user:midnightthoughts.space" - user_id = input(f"Enter your full user ID: [{user_id}] ") - - device_name = "matrix-call-multitrack-recorder" - # device_name = input(f"Choose a name for this device: [{device_name}] ") - - if not os.path.exists(STORE_PATH): - os.makedirs(STORE_PATH) - - client = AsyncClient( - homeserver, - user_id, - store_path=STORE_PATH, - config=client_config, - ) - pw = getpass.getpass() - - resp = await client.login(pw, device_name=device_name) - - # check that we logged in succesfully - if isinstance(resp, LoginResponse): - write_details_to_disk(resp, homeserver) - logger.info( - "Logged in using a password. Credentials were stored. " - "On next execution the stored login credentials will be used." - ) - else: - logger.info(f'homeserver = "{homeserver}"; user = "{user_id}"') - logger.error(f"Failed to log in: {resp}") - sys.exit(1) - - # Otherwise the config file exists, so we'll use the stored credentials - else: - # open the file in read-only mode - with open(CONFIG_FILE, "r") as f: - config = json.load(f) - client = AsyncClient( - config["homeserver"], - config["user_id"], - device_id=config["device_id"], - store_path=STORE_PATH, - config=client_config, - ) - - client.restore_login( - user_id=config["user_id"], - device_id=config["device_id"], - access_token=config["access_token"], - ) - return client +# Entry bot = None diff --git a/matrix_call_multitrack_recorder/bot.py b/matrix_call_multitrack_recorder/bot.py @@ -0,0 +1,295 @@ +# SPDX-FileCopyrightText: 2023-present MTRNord <support@nordgedanken.dev> +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import asyncio +import getpass +import json +import os +import sys +from nio import ( + AsyncClient, + LoginResponse, + MatrixRoom, + CallInviteEvent, + CallCandidatesEvent, + CallHangupEvent, + ToDeviceCallInviteEvent, + ToDeviceCallCandidatesEvent, + ToDeviceCallHangupEvent, + AsyncClientConfig, + InviteEvent, + MSC3401CallEvent, + to_device, + CallMemberEvent, + RoomMessageText, + ProfileGetDisplayNameResponse, +) + +from logbook import Logger, StreamHandler +import logbook +import sys + +from matrix_call_multitrack_recorder.recorder import Recorder + +StreamHandler(sys.stdout).push_application() +logger = Logger(__name__) +logger.level = logbook.INFO +# crypto.logger.level = logbook.DEBUG +to_device.logger.level = logbook.DEBUG + +# file to store credentials in case you want to run program multiple times +CONFIG_FILE = "credentials.json" # login credentials JSON file +# directory to store persistent data for end-to-end encryption +STORE_PATH = "./store/" # local directory + + +class RecordingBot: + client: AsyncClient + loop: asyncio.AbstractEventLoop + recorder: Recorder + + def __init__(self, client) -> None: + self.client = client + self.loop = asyncio.get_event_loop() + self.recorder = Recorder(self.client) + + async def start(self) -> None: + logger.info("Starting client") + await self.recorder.start() + + if self.client.should_upload_keys: + await self.client.keys_upload() + logger.debug("Uploaded keys") + + self.client.add_event_callback(self.message_callback, RoomMessageText) + self.client.add_event_callback(self.call_candidates, CallCandidatesEvent) + self.client.add_event_callback(self.call_invite, CallInviteEvent) + self.client.add_event_callback(self.call_hangup, CallHangupEvent) + self.client.add_event_callback(self.msc3401_call, MSC3401CallEvent) + self.client.add_event_callback(self.msc3401_call_member, CallMemberEvent) + + self.client.add_to_device_callback( + self.to_device_call_candidates, (ToDeviceCallCandidatesEvent,) + ) + self.client.add_to_device_callback( + self.to_device_call_invite, (ToDeviceCallInviteEvent,) + ) + self.client.add_to_device_callback( + self.to_device_call_hangup, (ToDeviceCallHangupEvent,) + ) + + self.client.add_event_callback(self.cb_autojoin_room, InviteEvent) + logger.info("Listening for calls") + + await self.client.sync_forever( + timeout=30000, full_state=True, set_presence="online" + ) + + async def stop(self) -> None: + logger.info("Stopping client") + await self.recorder.stop() + await self.client.close() + pass + + async def message_callback(self, room: MatrixRoom, event: RoomMessageText) -> None: + """Handles incoming messages.""" + + async def command_handling() -> None: + if event.body.startswith("!help"): + await self.client.room_send( + room.room_id, + "m.room.message", + { + "msgtype": "m.notice", + "body": "Please note that commands only work with the new voip (Element-Call)\n\nCommands:\n!help - Shows this help\n!stop - Stops the recording\n!start - Starts the recording", + }, + ignore_unverified_devices=True, + ) + elif event.body.startswith("!stop"): + await self.recorder.leave_call(room) + elif event.body.startswith("!start"): + await self.recorder.join_call(room) + + asyncio.create_task(command_handling()) + pass + + # TODO: Negotiate new streams + # TODO: Handle m.call.negotiate to-device events + # TODO: Send m.call.negotiate events with type offer + async def msc3401_call_member( + self, room: MatrixRoom, event: CallMemberEvent + ) -> None: + """Handles incoming MSC3401 call members.""" + if event.sender == self.client.user_id: + return + + logger.info(f"MSC3401 call member event: {event}") + if not event.calls: + await self.recorder.remove_connection(room) + + for call in event.calls: + for device in call["m.devices"]: + if device["device_id"] != self.client.device_id: + self.recorder.add_call(call["m.call_id"], room) + self.recorder.track_others( + call["m.call_id"], device["device_id"], event.sender + ) + # TODO: Can I reuse the same connection? Do I have the info needed? Is it a new connection? How do I see if it changed? + # asyncio.create_task(self.handle_call_invite(event, room)) + + async def msc3401_call(self, room: MatrixRoom, event: MSC3401CallEvent) -> None: + """Handles incoming MSC3401 calls.""" + self.recorder.add_call(event.state_key, room) + + async def call_invite(self, room: MatrixRoom, event: CallInviteEvent) -> None: + """Handles incoming call invites.""" + + asyncio.create_task(self.recorder.handle_call_invite(event, room)) + + async def to_device_call_invite(self, event: CallInviteEvent) -> None: + """Handles incoming call invites.""" + logger.info("Received to-device call invite") + + asyncio.create_task(self.recorder.handle_call_invite(event, None)) + + async def call_candidates( + self, room: MatrixRoom, event: CallCandidatesEvent + ) -> None: + """Handles incoming call candidates.""" + + asyncio.create_task(self.recorder.handle_call_candidates(room, event)) + + async def to_device_call_candidates(self, event: CallCandidatesEvent) -> None: + """Handles incoming call candidates.""" + logger.info("Received to-device call candidates") + + asyncio.create_task(self.recorder.handle_call_candidates(None, event)) + + async def call_hangup(self, room: MatrixRoom, event: CallHangupEvent) -> None: + """Handles call hangups.""" + asyncio.create_task(self.recorder.handle_call_hangup(room, event)) + + async def to_device_call_hangup(self, event: CallHangupEvent) -> None: + """Handles call hangups.""" + asyncio.create_task(self.recorder.handle_call_hangup(None, event)) + + async def cb_autojoin_room(self, room: MatrixRoom, event: InviteEvent): + """Callback to automatically joins a Matrix room on invite. + + Arguments: + room {MatrixRoom} -- Provided by nio + event {InviteEvent} -- Provided by nio + """ + + async def join_task(): + await self.client.join(room.room_id) + sender_display_name = await self.client.get_displayname(event.sender) + if isinstance(sender_display_name, ProfileGetDisplayNameResponse): + response = f"Hello, I am a bot that records calls. Use !help to see available commands. I was invited by {sender_display_name.displayname}" + else: + response = f"Hello, I am a bot that records calls. Use !help to see available commands. I was invited by {event.sender}" + await self.client.room_send( + room.room_id, + "m.room.message", + { + "msgtype": "m.text", + "body": response, + }, + ignore_unverified_devices=True, + ) + + asyncio.create_task(join_task()) + + logger.info(f"Joining room {room.room_id} on invite from {event.sender}") + + +def write_details_to_disk(resp: LoginResponse, homeserver) -> None: + """Writes the required login details to disk so we can log in later without + using a password. + + Arguments: + resp {LoginResponse} -- the successful client login response. + homeserver -- URL of homeserver, e.g. "https://matrix.example.org" + """ + # open the config file in write-mode + with open(CONFIG_FILE, "w") as f: + # write the login details to disk + json.dump( + { + "homeserver": homeserver, # e.g. "https://matrix.example.org" + "user_id": resp.user_id, # e.g. "@user:example.org" + "device_id": resp.device_id, # device ID, 10 uppercase letters + "access_token": resp.access_token, # cryptogr. access token + }, + f, + ) + + +async def login() -> AsyncClient: + client_config = AsyncClientConfig( + store_sync_tokens=True, + encryption_enabled=True, + ) + # If there are no previously-saved credentials, we'll use the password + if not os.path.exists(CONFIG_FILE): + logger.info( + "First time use. Did not find credential file. Asking for " + "homeserver, user, and password to create credential file." + ) + homeserver = "https://matrix.midnightthoughts.space" + homeserver = input(f"Enter your homeserver URL: [{homeserver}] ") + + if not (homeserver.startswith("https://") or homeserver.startswith("http://")): + homeserver = "https://" + homeserver + + user_id = "@user:midnightthoughts.space" + user_id = input(f"Enter your full user ID: [{user_id}] ") + + device_name = "matrix-call-multitrack-recorder" + # device_name = input(f"Choose a name for this device: [{device_name}] ") + + if not os.path.exists(STORE_PATH): + os.makedirs(STORE_PATH) + + client = AsyncClient( + homeserver, + user_id, + store_path=STORE_PATH, + config=client_config, + ) + pw = getpass.getpass() + + resp = await client.login(pw, device_name=device_name) + + # check that we logged in succesfully + if isinstance(resp, LoginResponse): + write_details_to_disk(resp, homeserver) + logger.info( + "Logged in using a password. Credentials were stored. " + "On next execution the stored login credentials will be used." + ) + else: + logger.info(f'homeserver = "{homeserver}"; user = "{user_id}"') + logger.error(f"Failed to log in: {resp}") + sys.exit(1) + + # Otherwise the config file exists, so we'll use the stored credentials + else: + # open the file in read-only mode + with open(CONFIG_FILE, "r") as f: + config = json.load(f) + client = AsyncClient( + config["homeserver"], + config["user_id"], + device_id=config["device_id"], + store_path=STORE_PATH, + config=client_config, + ) + + client.restore_login( + user_id=config["user_id"], + device_id=config["device_id"], + access_token=config["access_token"], + ) + return client diff --git a/matrix_call_multitrack_recorder/recorder.py b/matrix_call_multitrack_recorder/recorder.py @@ -0,0 +1,543 @@ +# SPDX-FileCopyrightText: 2023-present MTRNord <support@nordgedanken.dev> +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import asyncio +from dataclasses import dataclass +import os +import random +import string +from typing import Dict, Optional, Tuple, Union +from logbook import Logger, StreamHandler +import logbook +import sys +from nio import ( + AsyncClient, + CallInviteEvent, + MatrixRoom, + ToDeviceMessage, + CallCandidatesEvent, + RoomGetStateError, + ToDeviceCallCandidatesEvent, + CallHangupEvent, + ToDeviceCallInviteEvent, + ToDeviceCallHangupEvent, +) +from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack +from aiortc.contrib.media import MediaRecorder, MediaBlackhole +from aiortc.rtcicetransport import candidate_from_aioice +from aioice.candidate import Candidate +import time + +StreamHandler(sys.stdout).push_application() +logger = Logger(__name__) +logger.level = logbook.INFO + +# directory to store recordings +RECORDING_PATH = "./recordings/" # local directory + +# TODO: do not rely on room id +UserID = str +ConfID = str +RoomID = str +UniqueCallID = Tuple[UserID, ConfID] + + +@dataclass +class WrappedConn: + pc: RTCPeerConnection + prepare_waiter: asyncio.Future + candidate_waiter: asyncio.Future + room_id: Optional[str] + others: Optional[Dict[UserID, str]] + + +class Recorder: + conns: Dict[UniqueCallID, WrappedConn] + party_id: str + client: AsyncClient + loop: asyncio.AbstractEventLoop + conf_room: Dict[ConfID, MatrixRoom] + room_conf: Dict[RoomID, ConfID] + + def __init__(self, client) -> None: + self.client = client + self.loop = asyncio.get_event_loop() + + async def start(self) -> None: + self.conns = {} + self.conf_room = {} + self.room_conf = {} + self.party_id = "".join( + random.choices(string.ascii_letters + string.digits, k=8) + ) + + if not os.path.exists(RECORDING_PATH): + os.makedirs(RECORDING_PATH) + + logger.info("Starting recording handler") + + async def stop(self) -> None: + logger.info("Stopping recording handler") + for (_, conf_or_call_id), conn in self.conns.items(): + hangup = { + "content": { + "call_id": conf_or_call_id, + "version": 1, + "party_id": self.party_id, + } + } + if conn.room_id: + # We are lazy and send it to the room and as to_device message + await self.client.room_send( + conn.room_id, + "m.call.hangup", + hangup, + ignore_unverified_devices=True, + ) + else: + if conn.others: + # Send it as to_device message + for user_id, device_id in conn.others.items(): + message = ToDeviceMessage( + "m.call.hangup", + user_id, + device_id, + hangup, + ) + await self.client.to_device(message) + await self.client.room_put_state( + self.conf_room[conf_or_call_id].room_id, + "org.matrix.msc3401.call.member", + { + "m.calls": [ + { + "m.call_id": conf_or_call_id, + "m.devices": [], + } + ] + }, + state_key=self.client.user_id, + ) + await conn.pc.close() + pass + + def add_call(self, conf_id: ConfID, room: MatrixRoom) -> None: + logger.info(f"Adding conf {conf_id} to room {room.room_id}") + self.conf_room[conf_id] = room + self.room_conf[room.room_id] = conf_id + + async def leave_call(self, room: MatrixRoom) -> None: + if room.room_id in self.room_conf: + conf_id = self.room_conf[room.room_id] + if conf_id in self.conf_room: + del self.conf_room[conf_id] + del self.room_conf[room.room_id] + + for (user_id, conf_or_call_id), conn in list(self.conns.items()): + + if conn.others: + for (other_user_id, device_id) in conn.others.items(): + + hangup_message = ToDeviceMessage( + "m.call.hangup", + other_user_id, + device_id, + { + "content": { + "call_id": conf_id, + "version": 1, + "party_id": self.party_id, + } + }, + ) + await self.client.to_device(hangup_message) + + await conn.pc.close() + del self.conns[(user_id, conf_or_call_id)] + await self.client.room_put_state( + room.room_id, + "org.matrix.msc3401.call.member", + { + "m.calls": [ + { + "m.call_id": conf_id, + "m.devices": [], + } + ] + }, + state_key=self.client.user_id, + ) + + async def remove_connection(self, room: MatrixRoom) -> None: + if room.room_id in self.room_conf: + call_id = self.room_conf[room.room_id] + if (self.client.user_id, call_id) in self.conns: + del self.conns[(self.client.user_id, call_id)] + del self.room_conf[room.room_id] + del self.conf_room[call_id] + + def track_others(self, call_id: str, device_id: str, user_id: str) -> None: + call = self.conns[(self.client.user_id, call_id)] + if call.others is None: + call.others = {} + call.others[user_id] = device_id + + async def join_call(self, room: MatrixRoom) -> None: + if room.room_id not in self.room_conf: + logger.warning( + f"Room {room.room_id} is not a call room or we forgot. Trying to get call id from state" + ) + room_state = await self.client.room_get_state(room.room_id) + if isinstance(room_state, RoomGetStateError): + logger.warning(f"Could not get state for {room.room_id}: {room_state}") + return + + # Find "org.matrix.msc3401.call" state event + state = next( + ( + event + for event in room_state.events + if event["type"] == "org.matrix.msc3401.call" + ), + None, + ) + if state is None: + logger.warning(f"No call state event in {room.room_id}") + return + call_id = state["state_key"] + logger.info(f"Found call id {call_id} for {room.room_id}") + self.add_call(call_id, room) + await self.client.room_put_state( + room.room_id, + "org.matrix.msc3401.call.member", + { + "m.calls": [ + { + # TODO: Invalid call_id + "m.call_id": self.room_conf[room.room_id], + "m.devices": [ + { + "device_id": self.client.device_id, + "expires_ts": int(time.time() * 1000) + + (1000 * 60 * 60), + "session_id": f"{self.client.user_id}_{self.client.device_id}_session", + "feeds": [ + { + "purpose": "m.usermedia", + } + ], + } + ], + } + ] + }, + state_key=self.client.user_id, + ) + + async def handle_call_invite( + self, + event: Union[CallInviteEvent, ToDeviceCallInviteEvent], + room: Optional[MatrixRoom], + ) -> None: + if room: + logger.info(f"Received call invite from {event.sender} in {room.room_id}") + else: + logger.info(f"Received call invite from {event.sender}") + if event.expired: + logger.warning("Call invite expired") + return + if event.version != "1": + logger.warning("Call invite version not supported") + return + + if isinstance(event, ToDeviceCallInviteEvent): + if event.conf_id not in self.conf_room: + logger.warning("Got invalid to-device call invite") + return + + logger.info("Preparing call") + offer = RTCSessionDescription( + sdp=str(event.offer.get("sdp")), type=str(event.offer.get("type")) + ) + pc = RTCPeerConnection() + if isinstance(event, CallInviteEvent): + unique_id: UniqueCallID = (event.sender, event.call_id) + else: + unique_id: UniqueCallID = (event.sender, event.conf_id) + conn = self.conns[unique_id] = WrappedConn( + pc=pc, + candidate_waiter=self.loop.create_future(), + prepare_waiter=self.loop.create_future(), + room_id=room.room_id if room else None, + others={}, + ) + logger.info("Adding tracks") + input_tracks = {} + # output_tracks = {"audio": MediaBlackhole(), "video": MediaBlackhole()} + + async def task() -> None: + base_name_audio = f"{event.sender}_{event.call_id}" + base_name_video = f"{event.sender}_{event.call_id}" + if os.path.exists(os.path.join(RECORDING_PATH, f"{base_name_audio}.wav")): + i = 1 + while os.path.exists( + os.path.join(RECORDING_PATH, f"{base_name_audio}_{i}.wav") + ): + i += 1 + base_name_audio = f"{base_name_audio}_{i}" + + if os.path.exists(os.path.join(RECORDING_PATH, f"{base_name_video}.mp4")): + i = 1 + while os.path.exists( + os.path.join(RECORDING_PATH, f"{base_name_video}_{i}.mp4") + ): + i += 1 + base_name_video = f"{base_name_video}_{i}" + + wav_file = os.path.join(RECORDING_PATH, f"{base_name_audio}.wav") + mp4_file = os.path.join(RECORDING_PATH, f"{base_name_video}.mp4") + audio_recorder = MediaRecorder(wav_file, format="wav") + audio_recorder.addTrack(input_tracks["audio"]) + await audio_recorder.start() + if "video" in input_tracks: + video_recorder = MediaRecorder(mp4_file, format="mp4") + video_recorder.addTrack(input_tracks["video"]) + await video_recorder.start() + else: + video_recorder = None + + audio_track = input_tracks["audio"] + if "video" in input_tracks: + video_track = input_tracks["video"] + + @video_track.on("ended") + async def on_ended_video(): + if video_recorder: + await video_recorder.stop() + + @audio_track.on("ended") + async def on_ended_audio(): + await audio_recorder.stop() + + logger.info("Setting up callbacks") + + @pc.on("connectionstatechange") + async def on_connectionstatechange() -> None: + if pc.connectionState == "failed": + await pc.close() + self.conns.pop(unique_id, None) + if pc.connectionState == "connected": + asyncio.create_task(task()) + + @pc.on("track") + def on_track(track: MediaStreamTrack) -> None: + input_tracks[track.kind] = track + + # pc.addTrack(output_tracks[track.kind]) + + logger.info("Waiting for prepare") + await pc.setRemoteDescription(offer) + conn.prepare_waiter.set_result(None) + + if room and isinstance(event, CallInviteEvent): + logger.info("Sending receipt") + await self.client.update_receipt_marker(room.room_id, event.event_id) + + logger.info("Waiting for candidates") + await conn.candidate_waiter + + logger.info("Creating answer") + answer = await pc.createAnswer() + if not answer: + logger.warning("Failed to create answer") + await pc.close() + self.conns.pop(unique_id, None) + if room: + await self.client.room_send( + room.room_id, + "m.call.hangup", + { + "call_id": event.call_id, + "version": "1", + "party_id": self.party_id, + "reason": "ice_failed", + }, + ) + await self.client.room_send( + room.room_id, + "m.room.message", + { + "msgtype": "m.notice", + "body": f"Call with {event.sender} failed", + }, + ) + elif isinstance(event, ToDeviceCallInviteEvent): + if unique_id not in self.conns: + return + if not self.conns[unique_id].others: + return + else: + + if event.sender not in self.conns[unique_id].others: # type: ignore + return + message = ToDeviceMessage( + type="m.call.hangup", + recipient=event.sender, + recipient_device=self.conns[unique_id].others[event.sender], # type: ignore + content={ + "call_id": event.call_id, + "version": "1", + "party_id": self.party_id, + "reason": "ice_failed", + }, + ) + await self.client.to_device(message) + await self.client.room_send( + self.conf_room[event.conf_id].room_id, + "m.room.message", + { + "msgtype": "m.notice", + "body": f"Call with {event.sender} failed", + }, + ) + return + await pc.setLocalDescription(answer) + + logger.info("Sending answer") + if room: + answer = { + "call_id": event.call_id, + "version": "1", + "party_id": self.party_id, + "answer": { + # "type": pc.localDescription.type, + "type": "answer", + "sdp": pc.localDescription.sdp, + }, + } + await self.client.room_send( + room.room_id, "m.call.answer", answer, ignore_unverified_devices=True + ) + else: + answer = { + "call_id": event.call_id, + "version": "1", + "party_id": self.party_id, + "conf_id": event.source["content"]["conf_id"], + "capabilities": { + "m.call.transferee": False, + "m.call.dtmf": False, + }, + "answer": { + # "type": pc.localDescription.type, + "type": "answer", + "sdp": pc.localDescription.sdp, + }, + "device_id": self.client.device_id, + "dest_session_id": event.source["content"]["sender_session_id"], + "sender_session_id": f"{self.client.user_id}_{self.client.device_id}_session", + "seq": event.source["content"]["seq"], + } + to_device_message = ToDeviceMessage( + "m.call.answer", + event.source["sender"], + event.source["sender_device"], + answer, + ) + await self.client.to_device(to_device_message) + if room: + await self.client.room_send( + room.room_id, + "m.room.message", + { + "msgtype": "m.notice", + "body": "Successfully started recording", + }, + ignore_unverified_devices=True, + ) + logger.info(f"Sent answer to {event.sender} in {room.room_id}") + elif isinstance(event, ToDeviceCallInviteEvent): + await self.client.room_send( + self.conf_room[event.conf_id].room_id, + "m.room.message", + { + "msgtype": "m.notice", + "body": "Successfully started recording", + }, + ignore_unverified_devices=True, + ) + logger.info( + f"Sent answer to {event.sender} with device {event.source['sender_device']}" + ) + + async def handle_call_candidates( + self, + room: Optional[MatrixRoom], + event: Union[CallCandidatesEvent, ToDeviceCallCandidatesEvent], + ) -> None: + logger.info("Delaying candidates for 3 seconds") + await asyncio.sleep(3) + if room: + logger.info( + f"Received call candidates from {event.sender} in {room.room_id}" + ) + else: + logger.info(f"Received call candidates from {event.sender}") + + if isinstance(event, CallCandidatesEvent): + unique_id: UniqueCallID = (event.sender, event.call_id) + else: + unique_id: UniqueCallID = (event.sender, event.conf_id) + try: + conn = self.conns[unique_id] + except KeyError as e: + logger.warning("Received candidates for unknown call") + return + logger.info("Waiting for prepare") + await conn.prepare_waiter + logger.info("Adding candidates") + for raw_candidate in event.candidates: + if not raw_candidate.get("candidate"): + # End of candidates + conn.candidate_waiter.set_result(None) + break + try: + candidate = candidate_from_aioice( + Candidate.from_sdp(raw_candidate.get("candidate")) + ) + except ValueError as e: + logger.warning("Received invalid candidate: %s", e) + continue + candidate.sdpMid = raw_candidate.get("sdpMid") + candidate.sdpMLineIndex = raw_candidate.get("sdpMLineIndex") + + logger.info(f"Adding candidate {candidate} for {event.call_id}") + await conn.pc.addIceCandidate(candidate) + logger.info("Done adding candidates") + try: + conn.candidate_waiter.set_result(None) + except asyncio.InvalidStateError: + pass + if room and isinstance(event, CallCandidatesEvent): + await self.client.update_receipt_marker(room.room_id, event.event_id) + + async def handle_call_hangup( + self, + room: Optional[MatrixRoom], + event: Union[CallHangupEvent, ToDeviceCallHangupEvent], + ) -> None: + if room: + logger.info(f"Received call hangup from {event.sender} in {room.room_id}") + try: + if isinstance(event, CallHangupEvent): + unique_id: UniqueCallID = (event.sender, event.call_id) + else: + unique_id: UniqueCallID = (event.sender, event.conf_id) + await self.conns.pop(unique_id).pc.close() + if room and isinstance(event, CallHangupEvent): + await self.client.update_receipt_marker(room.room_id, event.event_id) + + except KeyError: + logger.warning("Received hangup for unknown call") + return diff --git a/pyproject.toml b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ - "matrix-nio[e2e] @ git+https://github.com/MTRNord/matrix-nio@289ee1dbcf37e1ebbed8d26d6ef026758dc46023", + "matrix-nio[e2e] @ git+https://github.com/MTRNord/matrix-nio@41e90f3f64bf43f4d3cbaa2b55652173073e5511", "aioice", "aiortc", "asyncio",