component conferences: fix session management and init:
- jingle session management is now correctly handled, with id of the Galène client used as prefix when necessary. - Galène is now run only when component session is started, avoiding systematic run on __init__. - Galène ICE logs are displayed with high verbosity. - Fix disco identity handling rel 447
This commit is contained in:
parent
067d8f32bf
commit
90e81c9fc6
4 changed files with 133 additions and 30 deletions
|
|
@ -23,6 +23,7 @@ from typing_extensions import TypedDict
|
|||
from twisted.words.protocols.jabber import jid as t_jid
|
||||
from twisted.words.protocols.jabber import xmlstream
|
||||
from twisted.words.xish import domish
|
||||
from wokkel import disco
|
||||
|
||||
|
||||
class SatXMPPEntity:
|
||||
|
|
@ -33,6 +34,7 @@ class SatXMPPEntity:
|
|||
is_component: bool
|
||||
server_jid: t_jid.JID
|
||||
IQ: Callable[[Optional[str], Optional[int]], xmlstream.IQ]
|
||||
identities: list[disco.DiscoIdentity]
|
||||
|
||||
|
||||
EncryptionPlugin = namedtuple(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
|
|
@ -42,14 +43,16 @@ from libervia.backend.core.i18n import _
|
|||
from libervia.backend.core.log import getLogger
|
||||
from libervia.backend.plugins.plugin_xep_0106 import XEP_0106
|
||||
from libervia.backend.plugins.plugin_xep_0166 import XEP_0166
|
||||
from libervia.backend.plugins.plugin_xep_0167 import mapping
|
||||
from libervia.backend.plugins.plugin_xep_0167 import XEP_0167, mapping, NS_AV_CONFERENCES
|
||||
from libervia.backend.plugins.plugin_xep_0176 import XEP_0176
|
||||
from libervia.backend.plugins.plugin_xep_0298 import XEP_0298
|
||||
from libervia.backend.tools.common import async_process, regex
|
||||
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
IMPORT_NAME = "conferences"
|
||||
NAME = "Libervia A/V Conferences"
|
||||
|
||||
PLUGIN_INFO = {
|
||||
C.PI_NAME: "A/V Conferences Component",
|
||||
|
|
@ -57,10 +60,10 @@ PLUGIN_INFO = {
|
|||
C.PI_MODES: [C.PLUG_MODE_COMPONENT],
|
||||
C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
|
||||
C.PI_PROTOCOLS: [],
|
||||
C.PI_DEPENDENCIES: ["XEP-0106", "XEP-0166", "XEP-0167", "XEP-0176"],
|
||||
C.PI_DEPENDENCIES: ["XEP-0106", "XEP-0166", "XEP-0167", "XEP-0176", "XEP-0298"],
|
||||
C.PI_RECOMMENDATIONS: [],
|
||||
C.PI_MAIN: "ConferencesComponent",
|
||||
C.PI_HANDLER: C.BOOL_FALSE,
|
||||
C.PI_HANDLER: C.BOOL_TRUE,
|
||||
C.PI_DESCRIPTION: _(
|
||||
"Handle multiparty audio/video conferences, using a Selective Forwarding Unit.\n"
|
||||
"The Galène project (https://galene.org) is currently the only supported backend."
|
||||
|
|
@ -79,6 +82,7 @@ class Conference:
|
|||
data_file: Path,
|
||||
endpoint: str,
|
||||
status_url: str,
|
||||
session: dict,
|
||||
) -> None:
|
||||
self.parent = parent
|
||||
self.group_name = group_name
|
||||
|
|
@ -88,6 +92,7 @@ class Conference:
|
|||
self._protocol: "GaleneProtocol|None" = None
|
||||
self.connected = defer.Deferred()
|
||||
self.ready = defer.Deferred()
|
||||
self.session = session
|
||||
|
||||
def __str__(self):
|
||||
return f"conference {self.group_name!r}"
|
||||
|
|
@ -102,10 +107,18 @@ class Conference:
|
|||
self._protocol = protocol
|
||||
self.connected.callback(None)
|
||||
|
||||
@property
|
||||
def client_id(self) -> str:
|
||||
return self.protocol.client_id
|
||||
|
||||
@property
|
||||
def _j(self) -> XEP_0166:
|
||||
return self.parent._j
|
||||
|
||||
@property
|
||||
def _rtp(self) -> XEP_0167:
|
||||
return self.parent._rtp
|
||||
|
||||
@property
|
||||
def _ice_udp(self) -> XEP_0176:
|
||||
return self.parent._ice_udp
|
||||
|
|
@ -116,6 +129,24 @@ class Conference:
|
|||
assert client is not None
|
||||
return client
|
||||
|
||||
@property
|
||||
def galene_id(self) -> str:
|
||||
"""ID to use with Galène API."""
|
||||
return f"{self.client_id}-{self.session['id']}"
|
||||
|
||||
async def on_call_setup(
|
||||
self,
|
||||
client: SatXMPPEntity,
|
||||
session: dict,
|
||||
call_data: dict,
|
||||
) -> None:
|
||||
if call_data["role"] == self._j.ROLE_INITIATOR:
|
||||
self.send_answer(session["id"], call_data["sdp"])
|
||||
else:
|
||||
raise exceptions.InternalError(
|
||||
'"role" should be "{self._j.ROLE_INITIATOR}" here.'
|
||||
)
|
||||
|
||||
def join(self, user_jid: jid.JID) -> None:
|
||||
self.protocol.send_data(
|
||||
{
|
||||
|
|
@ -127,29 +158,43 @@ class Conference:
|
|||
}
|
||||
)
|
||||
|
||||
def send_offer(self, session: dict, sdp: str) -> None:
|
||||
def send_request(self, requested: dict | None = None) -> None:
|
||||
if requested is None:
|
||||
requested = {"": ["audio", "video"]}
|
||||
self.protocol.send_data({"type": "request", "request": requested})
|
||||
|
||||
def send_offer(self, sdp: str) -> None:
|
||||
self.protocol.send_data(
|
||||
{
|
||||
"type": "offer",
|
||||
"id": session["id"],
|
||||
"id": self.galene_id,
|
||||
"label": "camera",
|
||||
"username": session["peer_jid"].userhost(),
|
||||
"username": self.session["peer_jid"].userhost(),
|
||||
"sdp": sdp,
|
||||
}
|
||||
)
|
||||
|
||||
def add_candidate(
|
||||
self, session: dict, candidate: dict, sdp_mid: str, sdp_mline_index: int
|
||||
) -> None:
|
||||
def send_answer(self, session_id: str, sdp: str) -> None:
|
||||
data_id = session_id[len(self.client_id) + 1 :]
|
||||
self.protocol.send_data(
|
||||
{
|
||||
"type": "answer",
|
||||
"id": data_id,
|
||||
"label": "camera",
|
||||
"username": self.session["peer_jid"].userhost(),
|
||||
"sdp": sdp,
|
||||
}
|
||||
)
|
||||
|
||||
def add_candidate(self, candidate: dict, sdp_mid: str, sdp_mline_index: int) -> None:
|
||||
"""Add an ICE candidate.
|
||||
|
||||
@param session: Jingle session.
|
||||
@param candidate: ICE candidate, SDP format.
|
||||
"""
|
||||
self.protocol.send_data(
|
||||
{
|
||||
"type": "ice",
|
||||
"id": session["id"],
|
||||
"id": self.galene_id,
|
||||
"candidate": {
|
||||
"candidate": mapping.generate_candidate_line(candidate),
|
||||
"sdpMid": sdp_mid,
|
||||
|
|
@ -169,15 +214,15 @@ class Conference:
|
|||
log.debug(f"Change for {user_jid} in {self}.")
|
||||
case "leave":
|
||||
log.info(f"{user_jid} has left {self}.")
|
||||
self.send_request()
|
||||
|
||||
def on_answer(self, data: dict) -> None:
|
||||
"""Called when SDP answer has been received
|
||||
|
||||
Send the SDP to ``answer_sdp_d`` to continue workflow.
|
||||
"""
|
||||
session = self._j.get_session(self.client, data["id"])
|
||||
try:
|
||||
answer_sdp_d = session.pop("answer_sdp_d")
|
||||
answer_sdp_d = self.session.pop("answer_sdp_d")
|
||||
except KeyError:
|
||||
raise exceptions.InternalError(
|
||||
'"answer_sdp_d" should be available in session.'
|
||||
|
|
@ -185,9 +230,29 @@ class Conference:
|
|||
else:
|
||||
answer_sdp_d.callback(data["sdp"])
|
||||
|
||||
def on_offer(self, data: dict) -> None:
|
||||
"""Called when a new SFP offer has been received"""
|
||||
defer.ensureDeferred(self.a_on_offer(data))
|
||||
|
||||
async def a_on_offer(self, data: dict) -> None:
|
||||
client: SatXMPPEntity = self.client.get_virtual_client(self.session["local_jid"])
|
||||
sid = await self._rtp.call_start(
|
||||
client,
|
||||
self.session["peer_jid"],
|
||||
{"sdp": data["sdp"]},
|
||||
session_id=f"{self.client_id}-{data['id']}",
|
||||
)
|
||||
session = self._j.get_session(client, sid)
|
||||
session["call_setup_cb"] = self.on_call_setup
|
||||
|
||||
def on_ice(self, data: dict) -> None:
|
||||
log.debug(f"ICE candidate: {data}")
|
||||
session = self._j.get_session(self.client, data["id"])
|
||||
if data["id"] == self.galene_id:
|
||||
session = self.session
|
||||
else:
|
||||
session = self._j.get_session(self.client, f"{self.client_id}-{data['id']}")
|
||||
log.debug(
|
||||
f"ICE candidate for session {session['id']}, peer_jid={session['peer_jid']}."
|
||||
)
|
||||
candidate_data = data["candidate"]
|
||||
contents = session["contents"]
|
||||
try:
|
||||
|
|
@ -199,6 +264,7 @@ class Conference:
|
|||
return
|
||||
content = contents[content_id]
|
||||
local_ice_data = content["transport_data"]["local_ice_data"]
|
||||
|
||||
media = content["application_data"]["media"]
|
||||
client: SatXMPPEntity = self.client.get_virtual_client(session["local_jid"])
|
||||
candidate = mapping.parse_candidate(candidate_data["candidate"][10:].split())
|
||||
|
|
@ -225,7 +291,10 @@ class GaleneClientFactory(WebSocketClientFactory):
|
|||
|
||||
|
||||
class GaleneProtocol(WebSocketClientProtocol):
|
||||
verbose = True
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.client_id = str(uuid())
|
||||
|
||||
@property
|
||||
def conference(self) -> Conference:
|
||||
|
|
@ -241,12 +310,12 @@ class GaleneProtocol(WebSocketClientProtocol):
|
|||
super().connectionLost(reason)
|
||||
|
||||
def onOpen(self):
|
||||
handshake_data = {"type": "handshake", "version": ["2"], "id": str(uuid())}
|
||||
handshake_data = {"type": "handshake", "version": ["2"], "id": self.client_id}
|
||||
self.send_data(handshake_data)
|
||||
|
||||
def send_data(self, data: dict) -> None:
|
||||
if self.verbose:
|
||||
log.debug(f"DATA SENT: {data}")
|
||||
if ConferencesComponent.verbose:
|
||||
log.debug(f"=> DATA SENT [{self.client_id}]: {data}")
|
||||
self.sendMessage(json.dumps(data).encode())
|
||||
|
||||
def onMessage(self, payload, isBinary):
|
||||
|
|
@ -258,6 +327,8 @@ class GaleneProtocol(WebSocketClientProtocol):
|
|||
log.warning(f"Can't decode data: {payload!r}")
|
||||
return
|
||||
|
||||
if ConferencesComponent.verbose:
|
||||
log.debug(f"<= DATA RECEIVED [{self.client_id}]: {data}")
|
||||
try:
|
||||
match data.get("type"):
|
||||
case None:
|
||||
|
|
@ -266,14 +337,14 @@ class GaleneProtocol(WebSocketClientProtocol):
|
|||
version = data["version"][0]
|
||||
|
||||
log.debug(
|
||||
f"Handshake for group {self.conference.group_name!r}. Galène protocol "
|
||||
f" v{version}."
|
||||
f"Handshake for group {self.conference.group_name!r}. Galène "
|
||||
f"protocol v{version}."
|
||||
)
|
||||
self.conference.ready.callback(None)
|
||||
case "ping":
|
||||
log.debug("pong")
|
||||
self.send_data({"type": "pong"})
|
||||
case "joined" | "answer" | "ice" as data_type:
|
||||
case "joined" | "answer" | "ice" | "offer" as data_type:
|
||||
method = getattr(self.conference, f"on_{data_type}")
|
||||
method(data)
|
||||
case _:
|
||||
|
|
@ -284,14 +355,26 @@ class GaleneProtocol(WebSocketClientProtocol):
|
|||
|
||||
class ConferencesComponent:
|
||||
IMPORT_NAME = IMPORT_NAME
|
||||
verbose = 0
|
||||
|
||||
def __init__(self, host):
|
||||
self.host = host
|
||||
self.client: SatXMPPEntity | None = None
|
||||
self._e: XEP_0106 = host.plugins["XEP-0106"]
|
||||
self._j: XEP_0166 = host.plugins["XEP-0166"]
|
||||
self._rtp: XEP_0167 = host.plugins["XEP-0167"]
|
||||
self._ice_udp: XEP_0176 = host.plugins["XEP-0176"]
|
||||
self._coin: XEP_0298 = host.plugins["XEP-0298"]
|
||||
host.trigger.add("XEP-0167_jingle_handler", self._jingle_handler_trigger)
|
||||
self.initalized = False
|
||||
|
||||
def _init(self) -> None:
|
||||
"""Initialisation done after profile is connected"""
|
||||
assert self.client is not None
|
||||
self.client.identities.append(
|
||||
disco.DiscoIdentity("conference", "audio-video", NAME)
|
||||
)
|
||||
|
||||
try:
|
||||
galene_path = Path(
|
||||
self.host.memory.config_get(CONF_SECTION, "galene_path")
|
||||
|
|
@ -306,10 +389,15 @@ class ConferencesComponent:
|
|||
self.galene_http_port = self.host.memory.config_get(
|
||||
CONF_SECTION, "http_port", "9443"
|
||||
)
|
||||
galene_data_path = host.memory.get_cache_path(IMPORT_NAME, "galene")
|
||||
galene_data_path = self.host.memory.get_cache_path(IMPORT_NAME, "galene")
|
||||
galene_static_path = galene_path.parent / "static"
|
||||
self.galene_group_path = galene_data_path / "groups"
|
||||
self.galene_group_path.mkdir(0o700, parents=True, exist_ok=True)
|
||||
env = os.environ
|
||||
if self.verbose >= 2:
|
||||
# We activate Galène debug logs on ICE candidates.
|
||||
env["PION_LOG_TRACE"] = "ice"
|
||||
|
||||
try:
|
||||
d = self._process = async_process.run(
|
||||
str(galene_path),
|
||||
|
|
@ -320,7 +408,8 @@ class ConferencesComponent:
|
|||
# We don't want HTTPS here, it's only used for local interactions
|
||||
"-insecure",
|
||||
path=str(galene_data_path),
|
||||
verbose=True,
|
||||
verbose=bool(self.verbose),
|
||||
env=env,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Can't start Galene.")
|
||||
|
|
@ -331,8 +420,11 @@ class ConferencesComponent:
|
|||
def get_handler(self, __):
|
||||
return ConferencesHandler()
|
||||
|
||||
def profile_connecting(self, client):
|
||||
def profile_connecting(self, client: SatXMPPEntity) -> None:
|
||||
self.client = client
|
||||
if not self.initalized:
|
||||
self._init()
|
||||
self.initalized = True
|
||||
|
||||
async def attach_to_group(self, session: dict, group_name: str) -> Conference:
|
||||
"""Attach to a Galène group.
|
||||
|
|
@ -372,6 +464,7 @@ class ConferencesComponent:
|
|||
data_file=data_file,
|
||||
endpoint=endpoint,
|
||||
status_url=status_url,
|
||||
session=session,
|
||||
)
|
||||
|
||||
factory = GaleneClientFactory(conference)
|
||||
|
|
@ -418,9 +511,14 @@ class ConferencesComponent:
|
|||
except KeyError:
|
||||
raise exceptions.InternalError("Conference data is missing.")
|
||||
|
||||
await conference.ready
|
||||
conference.join(session["peer_jid"])
|
||||
conference.send_offer(session, call_data["sdp"])
|
||||
if call_data["role"] == self._j.ROLE_RESPONDER:
|
||||
await conference.ready
|
||||
conference.join(session["peer_jid"])
|
||||
conference.send_offer(call_data["sdp"])
|
||||
else:
|
||||
raise exceptions.InternalError(
|
||||
'"role" should be "{self._j.ROLE_RESPONDER}" here.'
|
||||
)
|
||||
|
||||
def on_ice_candidates_new(
|
||||
self,
|
||||
|
|
@ -443,14 +541,15 @@ class ConferencesComponent:
|
|||
sdp_mid: str = content_id
|
||||
|
||||
for candidate in media_data["candidates"]:
|
||||
conference.add_candidate(session, candidate, sdp_mid, sdp_mline_index)
|
||||
conference.add_candidate(candidate, sdp_mid, sdp_mline_index)
|
||||
|
||||
def _galene_process_errback(self, failure_: Failure) -> None:
|
||||
log.error(f"Can't run Galene process: {failure_.value}")
|
||||
|
||||
|
||||
|
||||
@implementer(iwokkel.IDisco)
|
||||
class ConferencesHandler(XMPPHandler):
|
||||
|
||||
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
|
||||
return [disco.DiscoIdentity("conference", "audio-video")]
|
||||
return [disco.DiscoFeature(NS_AV_CONFERENCES)]
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from .constants import (
|
|||
NS_JINGLE_RTP_AUDIO,
|
||||
NS_JINGLE_RTP_INFO,
|
||||
NS_JINGLE_RTP_VIDEO,
|
||||
NS_AV_CONFERENCES
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,3 +25,4 @@ NS_JINGLE_RTP_AUDIO: Final = f"{NS_JINGLE_RTP_BASE}:audio"
|
|||
NS_JINGLE_RTP_VIDEO: Final = f"{NS_JINGLE_RTP_BASE}:video"
|
||||
NS_JINGLE_RTP_ERRORS: Final = f"{NS_JINGLE_RTP_BASE}:errors:1"
|
||||
NS_JINGLE_RTP_INFO: Final = f"{NS_JINGLE_RTP_BASE}:info:1"
|
||||
NS_AV_CONFERENCES: Final = "urn:xmpp:jingle:av-conferences:0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue