diff --git a/debian/changelog b/debian/changelog index 19b9f58..993aaf9 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,861 +1,867 @@ +sylkserver (6.0.0) unstable; urgency=medium + + * Migrate to Python 3 + + -- Adrian Georgescu Wed, 16 Jun 2021 09:22:45 +0200 + sylkserver (5.8.0) unstable; urgency=medium * [webrtcgateway] Added support for SIP Messages (RFC 3428) * [webrtcgateway] Added support for CPIM payloads (RFC 5438) * [webrtcgateway] Added support for IMDN notifications (RFC 5438) * [webrtcgateway] Delay cleanup in videoroom hangup event to prevent colliding with 'leaving' * [webrtcgateway] Allow janus decline code to be defined in configuration * [webrtcgateway] Update WebRTC readme * [xmpp-gateway] Added TLS server support https://xmpp.org/extensions/attic/xep-0368-0.1.html * [xmpp-gateway] Added XMPP Muc Subject stanza * [xmpp-gateway] Improve XMPP/SIP Message interoperability * [xmpp-gateway] Fixed encoding/decoding XMPP/SIP gateway messaging * [xmpp-gateway] Improve logging * [xmpp-gateway] Handle RTCP in Jingle media SDP * [xmpp-gateway] Fixed accumulating duplicate Jingle candidates * [xmpp-gateway] Fixed handling is_composing events * [xmpp-gateway] Added description to exception raised by broken Jingle payload * [webrctgatway] Added content type and IMDN status to logs * [webrctgatway] Added support for sending and receiving SIP Messages and IMDN * [webrctgatway] Delay cleanup in videoroom hangup event to prevent colliding with 'leaving' * [webrctgatway] Allow janus decline code to be defined in configuration * [conference] Added subject to conference info description * [core] Initialize PJSIP engine with a fixed name server -- Adrian Georgescu Wed, 16 Jun 2021 09:21:45 +0200 sylkserver (5.7.0) unstable; urgency=medium * Added (external) IMAP authentication for webrtcgateway The authentication type can be configured per domain in auth.ini * Added apache-cassandra as storage backend * Added session_id based on callid to conference invite * Added support for audio only conferences * Added support for 'ping' in sylkrtc protocol * Added support for early media * Added user agent to token storage * Changed Firebase push notifications to Sylk push server * Exposed call-id in API for SIP calls * Fixed destroying videoroom when client is disconnected * Fixed push media types * Fixed rejecting SIP methods in webrtc app * Improved logging of push notifications * Improved webrtc video room logging * Mention how to enable debug mode * Read device token from push response on 410 code * Replaced call-id by random uuid in push notification and conference invite * Replaced session id on incoming call to uuid from callid * Store push tokens and parameters in pickle file * Use push notification token for VoIP -- Adrian Georgescu Wed, 14 Oct 2020 15:05:36 +0200 sylkserver (5.6.0) unstable; urgency=medium * Added videoroom capability to raise/lower your hand * Added videoroom capability to send mute command to all participants -- Adrian Georgescu Tue, 07 Apr 2020 14:40:43 +0200 sylkserver (5.5.0) unstable; urgency=medium * Fixed typing information * Converted method to static method * Silenced some IDE weak warnings * Removed commented out variable in debian rules * Explicitly use python2 in shebang lines * Do not ship a sysv init script anymore * Simplified MANIFEST.in * Increased debian standards version to 4.5.0 * Use pybuild as the debian build system * Updated copyright years -- Dan Pascu Fri, 14 Feb 2020 14:38:18 +0200 sylkserver (5.4.0) unstable; urgency=medium * Refactored VideoroomSessionInfo to simplify usage * Fixed correctness and consistency issue with getting the display name * Accept optional auth credentials argument in Session.connect * Added chat room capabilities to the video conferences * Fixed json model for the slowlink event from janus * Updated python-sipsimple dependency * Update README with WebRTC multi-party conference features * Improved some log messages and updated their log level * PEP-8 compliance change * Refactored conference welcome message * Fixed notification name -- Dan Pascu Wed, 11 Dec 2019 18:14:43 +0200 sylkserver (5.3.0) unstable; urgency=medium * Added file transfer support to video conferences in webrtc gateway * Modified SIP and Videoroom sessions to have a reference to the account * Removed unnecessary variable * Fixed spelling * Use the new standard runtime directory in the init file * Updated systemd service file to use the new option to run as a service * Added new command line option to enable verbose logging * Refactored code that deals with memory debugging * Added a new command line option to run as a systemd service * Use the argparse module to parse command line arguments * Adapted to API changes in python-application 2.8.0 * Renamed function argument -- Dan Pascu Fri, 11 Oct 2019 16:43:38 +0300 sylkserver (5.2.0) unstable; urgency=medium * Updated TLS certificates -- Dan Pascu Wed, 27 Feb 2019 13:57:07 +0200 sylkserver (5.1.1) unstable; urgency=medium * Fixed attribute name to match latest sipsimple internals * Fixed a race condition that leaked audio resources -- Dan Pascu Wed, 27 Feb 2019 10:13:20 +0200 sylkserver (5.1.0) unstable; urgency=medium * Replaced obsolete ptype value for subscribers * Avoid unnecessary list copy * Removed unnecessary boolean variable * Do not reset the proposed_streams attribute when the session is rejected * Reordered some operations for consistency * Make sure the greenlet attribute is always removed when greenlet exits * Removed code that duplicated cancel handling inside reject handler * Fixed spacing to be PEP-8 compliant * Check if attribute is present * Removed unnecessary attribute from AudioStream * Avoid using tracking variables in favor of a more pythonic code * Fixed memory leak that did not release streams when canceling proposals * Use setter syntax for defining properties * Turned unnecessary list comprehensions into iterators * Always clean up proposed streams when cancelling proposal -- Dan Pascu Mon, 25 Feb 2019 12:47:24 +0200 sylkserver (5.0.0) unstable; urgency=medium * [webrtcgateway] Removed bogus attributes * [webrtcgateway] Fixed exception handling for validating json model * [webrtcgateway] Cleanup accounts when the client connection is lost * Removed data duplication and inconsistent attributes * Fixed argument types * Moved DNS lookup log message to debug level * Simplified building the result of the DNS lookup * Simplified and fixed DNS lookup when no outbound proxy is involved * Generalized sylkrtc json model mapping * Removed unnecessary default value * Avoid duplicate indexing when getting the data * Use python3 compatible form for except statements * Avoid unnecessary attach/detach in janus if DNS lookup fails * Added debug_level setting to ServerConfig * Added ability to toggle between configured log level and debug at runtime * Removed unused import and order imports alphabetically * Set videoroom bitrate to overwrite janus' low default of 256Kb/s * Added API call to allow a client to update session parameters on the fly * Register atexit handler to dump memory debug info * Simplified and enhanced application loading/accessing code * Fixed spurious SIPApplicationWillStart post and handling early stop * The __init__ method should not return any value * Made policy matching logic consistent and simplified code * Refactored logging - Updated code to use the new logging features from python-application. - Rewrote system logging to be more readable and easier to interpret. - Implemented trace loggers based on notifications to separate and isolate them from the code that generates the trace log events. - Added a trace logger for DNS lookups. * Added bitrate and video_codec configuration options for video rooms * Fully cleanup the connection handler resources when client disconnects * Simplified getting the session with the handle_id * Rewrote VideoRoomSessionContainer to not use weak references. When sessions were removed from the container, there was no guarantee that there was no other reference to them somewhere else in the code, which would cause dangling id-to-session mappings to be kept around in the container for undetermined periods of time. * Renamed VideoRoomSessionContainer to SessionContainer * Fixed order of operations during ConnectionHandler cleanup * Added PublisherFeedContainer class for holding videoroom publisher feeds * Use SessionContainer to hold SIP sessions instead of using 2 mappings * Provide more context in log messages to identify where they're generated * Changed default max_bitrate value from 4Mb/s to 2Mb/s * Automatically adjust participant bitrate based on number of participants * Do not warn about missing session (it might've been destroyed elsewhere) * Do session cleanup after all processing * Separated session hangup event handler * Simplified handling hangup events as they do not have an associated code * Eliminated delays when deleting sessions and video rooms * Avoid an unnecessary base session lookup * Verify that the detached feed belongs to the requester * Use dict constructors instead of dict literals for readability * Fixed race condition when deleting SIP session * Fixed exception while deleting subscriber sessions during hangup events * Use discard as session might not be yet added to the video room * Handle JanusError exceptions and relay errors back to client * Properly release resources when getting JanusError exceptions * Do not treat DNS lookup errors as API errors * Fixed invite to conference to not include oneself * Simplified finding the invited accounts * Made method for handling conference invites public * Made VideoRoomContainer consistent by not relying on weak references * Moved video room access validation from ConnectionHandler to VideoRoom * Simplified building request data for SIP calls and registrations * Removed unnecessary room attribute * Simplified code and increased readability * Unified handling of exceptions for the video room join operation * Send back error responses for videoroom-ctl requests with missing fields * Use modern syntax for catching exceptions * Improved PEP-8 compliance * Removed copyright notice from script * Updated license * Use the actual config type and file to find out which files are read * Improved some log messages * Do not log traceback twice * Removed duplication of startup error handling * Added run method on SylkServer * Use SylkServer.run to decouple from SylkServer's internals * Refactored startup script and configuration handling - Removed command line option to specify the pid - Removed command line option to specify the main configuration file - Added a command line option to specify the configuration directory - Added a command line option to specify the runtime directory - The main configuration file name is now immutable (config.ini) - The pid file name is immutable (sylk-server.pid) - All configuration files are now treated consistently - Properly read both local and system wide configurations - Do not initialize the runtime directory when not forking * Simplified the setup.py script and improved PEP-8 compliance * Added requirement on the jsonmodels python package * Do not split statements over multiple lines when not needed * Improve validator efficiency by using objects with a validate method * Do not use slow regular expression for validating SIP URIs * Fixed finding resources when forking and started with a relative path * Log the resources directory on startup * Better names for some of the json model classes * Added LimitedChoiceField for json models * Replaced emdash HTML code with the actual character * Use a local copy of bootstrap.min.css * Updated authors and sponsors * Split videoroom-ctl handler into subhandlers per option * Simplified getting static web resources for webrtcgateway * Make the web resource available as a property * Fixed handling ack messages * Simplified getting the result from janus messages * Added support for setting the active participants in a room * Log unhandled exception details * Only update active participants if different * Added UniqueStringListField JSON model field type * Moved normalization of the active participant list to the JSON model * Use the UniqueStringListField type for the invited participants * Split operation handlers into request and event handlers * Moved boilerplate code out of the request handlers * Moved general exception handling into operation handlers * Wait for the connection to be ready once before processing operations * Added generic sylkrtc request to model mapper * Simplified instantiating sylkrtc models from requests * Removed code that was never reached * Restructured code to avoid unnecessary extra return statements * Removed unnecessary additional private method * Fixed race condition when a connection handler is stopped while starting * Fixed race condition when cleaning up a connection handler while stopping * Removed unnecessary partial function * Simplified keepalive callback * Simplified webrtcgateway package structure * Removed unnecessary references to the janus backend everywhere * Renamed attribute to better reflect its function * Renamed some arguments and variables for clarity * Refactored sending keepalives to be internal to the protocol * Simplified stopping the keepalive timer * Grouped public/private API methods together * Explicitly added protocol methods to JanusBackend * Removed redundant namespace prefix * Optimized code to only send keepalive messages when necessary * Simplified code sending janus requests * Do not allow request arguments to overwrite core attributes * Do not attempt to send messages after the connection was closed * Fixed return value from deferred callback * Fixed error with cancelling already called timer * Replaced dependency on python-jsonmodels with internal module The jsonobjects module was written to replace the functionality provided by jsonmodels while offering the following advantages: - More than 10 times faster - Much lower memory footprint - No need for manual validation - Objects are validated on creation and every time they are modified - Guaranteed data consistency throughout the lifetime of the objects - JSON arrays are first class objects allowing them to be used standalone - JSON arrays can be embedded alowing for multi-dimensional arrays - JSON arrays can define both item and array level validators - Better semantics for optional properties and default values * Added ConnectionHandler.send method to replace notify and _send_response * Renamed VideoRoom to Videoroom in class names * Refactored client API to simplify it and make it consistent * Simplified AbstractProperty getter * Improved data extraction speed by a factor of 2 * Have the 'in' operator to check if a property is defined in a JSONObject * Fixed code formatting warnings * Removed unnecessary class * Removed unnecessary util.py module * Optimized returned value * Allow LimitedChoiceProperty to be optional and have a default value * Keep all internal SIP URIs as AORs for consistency * Moved FixedValueProperty and LimitedChoiceProperty to jsonobjects.py * Moved validators to validators.py * Added DisplayNameValidator that removes optional quotes * Added type hinting for sylkrtc models * Fixed DisplayNameValidator to work with empty names * Use JSON models to encode/decode messages to/from Janus - Encapsulated Janus session and plugin handle functionality to classes - Simplified generating Janus requests and processing Janus replies - Correctly handle Janus errors sent as error events in success replies - Split Janus event handlers to improve readability and maintability - Moved data validation to the JSON model validators * Use UTC timestamps in push notifications. The local timezone of sylkserver is both irrelevant for the client that receives the push notification and it's also 20 times slower to generate a server local timestamp than a UTC timestamp (115us vs 6us) * Use JSON models for Firebase push notifications * Added method to send conference invite push notifications * Renamed push notification methods from xyz_session to xyz_call * Simplified signature for the push notification functions * Fixed race condition with cleaning subscriber sessions in conferences * Split debian dependencies one per line * Removed dependency on dummy package * Removed unnecessary version dependencies * Updated debian package description * Improve performance for push notifications by reusing the request * Log push notifications at DEBUG level * Avoid composing log message unless needed * Use log.warning instead of its alias log.warn * Updated python-application version dependency * Updated dependency list in the INSTALL file * Use default sound for push notifications * Send push notifications for conference invite events * Use relative imports for webrtcgateway * Adjusted the WebRTC gateway URL in the sample configuration * Fixed URL class to not mangle template variable * Adjusted conference welcome message * Fixed sending ACK to the wrong address by SofiaSIP * Ignore the Janus SIP plugin ringing event for now * Ignore the Janus SIP plugin progress event for now * Fixed matching empty file:// origins with the latest autobahn * Moved debian dependency to the proper package * Drop support for autobahn older than 0.12 * Removed deprecated pycompat/pyversions files * Removed unnecessary .PHONY target * Removed code that prevented sylkserver from restarting on upgrades * Do not install janus configuration files with sylkserver-webrtc-gateway * Removed defaults file * Allow sylkserver to leave a core file when run by systemd * Removed build dependency on obsolete package * Increased debian compatibility to 11 * Synced MANIFEST.in file with latest file changes * Rename .ini.sample files to .ini in the debian package * Fixed corrupted sound file names in the deb if name contains spaces -- Dan Pascu Fri, 05 Oct 2018 21:50:21 +0300 sylkserver (4.1.0) unstable; urgency=medium * Mention implemented standards for WebRTC video conferencing * Improve logging of WebRTC gateway and video conference applications * Added per video room access control and recording options * Fix settings the recording folder for video conference recordings * Fixed chatroom capabilities * Changed codec order * Changed sample rate to favor opus * Adjusted sample config with latest default values * webrtcgw: improved logging of incoming connections * webrtcgw: initial implementation of push notifications framework * webrtcgw: fix sample configuration file * webrtcgw: reorganized package * webrtcgw: fix for AutoBahn API change * webrtcgw: simplify ICE state flags * webrtcgw: uncomment log lines * webrtcgw: set content_available to True for FCM notifications * Capture validation errors when building requests * The new_token field is not required for the account-devicetoken request * Added extra logging to help debug device token handling * Increased debian compatibility level to 9 * Increased debian standards version * Updated debian package maintainer * Added debian dependency on lsb-base * Adjusted debian package's descriptions * Updated boring file -- Dan Pascu Fri, 20 Jan 2017 11:36:01 +0200 sylkserver (4.0.0) unstable; urgency=medium * webrtc: add multi-party video conferencing capability * webrtc: separate Janus event handlers per plugin * webrtc: refactor JanusSessionInfo * webrtc: reorganize API module * webrtc: update builtin HTML description page * core: add support for TLS certificate chains for the builtin web server * echo: refactor application * conference: advertise call-by-uri WebRTC URIs * conference: don't advertise XMPP support by default * conference: advertise other means to join a room also when in bonjour mode -- Saul Ibarra Wed, 12 Oct 2016 08:43:02 +0200 sylkserver (3.3.0) unstable; urgency=medium * webrtcgateway: refactor API message handling * webrtcgateway: reorganize models * webrtcgateway: add support for setting account display name * webrtcgateway: remove obsolete sylkrtc test application * webrtcgateway: add ability to customize User Agent * Raised Janus version dependency * Update Janus configuration -- Saul Ibarra Tue, 14 Jun 2016 15:55:33 +0100 sylkserver (3.2.0) unstable; urgency=medium * Fix per-room pstn_access_numbers setting * Fixup leftover old streams API usage * webrtcgateway: skip 'detached' event * Update references, some of the drafts are now RFCs * Fix overriding local_uri for MSRP streams * Fix sending XMPP messages after API changes * Fix method name * webrtcgateway: enable WebSocket pinging * Un-vendor Klein * Disable i/o buffering when running with systemd * Fix access to MediaStreamRegistry after SDK upgrade * Make the Jingle MediaStreamRegistry analogous to the SIP one * Catch exceptions when accepting incoming subscriptions * Don't set GnuTLS compression parameters * Adapt to API changes in SIPThor * Several code style improvements * Log errors when setting up streams in new_from_sdp * Remove mismatched HTML closing tag * Handle parsing errors for is-composing payload * Reject incoming sessions with a Replaces header * Fixed compatibility with AutoBahn >= 0.12 * webrtcgateway: enable optional SRTP-SDES for outgoing calls * Adapted to changes in SIPSIMPLE SDK 3.0.0 * Update INSTALL * Use collections.Counter instead of a custom defaultdict * Simplified logic for starting server * Added command line option for memory debugging * Adapt to transpoert API change in Jingle streams * Use new notification to listen for Engine failures * Forcefully exit if we fail to start TLS * Join the Engine thread just for 5 seconds * xmppgateway: fix unicode error when sending MSRP chunks -- Saul Ibarra Tue, 08 Mar 2016 13:01:10 +0100 sylkserver (3.1.0) unstable; urgency=medium * Fixed default web port in sample config file * Terminate connections if backend goes down * webrtc: fix navbar rounded corners in test app * Update bundled sylkrtc.js library * webrtc: show remote party in test app * Improve error messages for API call errors * Exit with a a non-zero exit code if engine failed * Update README with WebRTC related information * Added 'missed_session' event * Added webrtc_gateway_url settings for conference rooms * Adapt to changes in SIP SIMPLE SDK * Raised python-sipsimple dependency * Updated Janus config to match new version * Raised Janus version dependency * webrtc: add display name support for incoming and missed calls -- Saul Ibarra Fri, 04 Dec 2015 12:52:26 +0100 sylkserver (3.0.1) unstable; urgency=medium * webrtc: mute local video in test application * Adjust web port in configuration example * Fix installing default certificates also in /usr/share/ -- Saul Ibarra Fri, 04 Sep 2015 12:32:22 +0200 sylkserver (3.0.0) unstable; urgency=medium * Added WebRTC gateway application * Switch to using listenSSL * Make main web server logging less verbose * Fix initializing Path datatype * Rework how services are published in SIPThor * Install all sample configuration files * xmppgateway: make factories not noisy * Add systemd unit file * Improved Debian package creation * Added build dependency on dh-python -- Saul Ibarra Fri, 28 Aug 2015 16:41:06 +0200 sylkserver (2.10.1) unstable; urgency=medium * Added missing dependency on python-werkzeug -- Saul Ibarra Tue, 16 Jun 2015 15:50:52 +0200 sylkserver (2.10.0) unstable; urgency=medium * Add global web server that applications can tap into * Refactor web capabilities of conference application * Make loading and starting applications more resilient * Log exceptions when initializing/starting/stopping applications * Log application map on startup * Reorganized some code in the echo application * Fix building FileSelector objects out of RoomFile objects * Fixed using undefined variable -- Saul Ibarra Mon, 15 Jun 2015 16:17:26 +0200 sylkserver (2.9.1) unstable; urgency=medium * Add spool_dir setting * Simplify SylkServer's stream subclasses * Stop the session manager first when shutting down * Adapt to API changes in MSRPlib * Refactor file transfers to match API changes in SIP SIMPLE SDK -- Saul Ibarra Wed, 29 Apr 2015 14:17:42 +0200 sylkserver (2.9.0) unstable; urgency=medium * Added ZRTP and opportunistic encryption support * Adapt to changes in SIP SIMPLE SDK * Add python-lxml as a direct dependency * Relax XMPP - SIP URI marching * Accept any content type in echo application * Support inlined images in the conference application * Add setting for toggling presence activity logging (xmppgateway) * Refactored path handling and TLS certificate location * Simplify default paths for resources in /var * Add ability to skip the isfocus parameter when publishing a Bonjour service * Publish echo application on Bonjour if enabled * Publish playback application on Bonjour if enabled * Change default directory for conference file transfers * Tag all messages sent by the room as status messages * Publish every Bonjour service with a different id -- Saul Ibarra Tue, 17 Mar 2015 09:28:54 +0100 sylkserver (2.8.0) unstable; urgency=medium * Add a custom Session class * Added setting for toggling ICE support * Add advertised_ip setting * Use the specified IP address both for signaling and media * Enhance playback application * Adapt to latest SDK API changes * Don't advertise the default conference on Bonjour if it's not the default application * Add ability to find applications by name to ApplicationRegistry * Log default application on start * Use 127.0.0.1 if the local address could not be determined * Refactor managing the single account SylkServer currently uses * Rename sylk.extensions to sylk.streams * Refactored WelcomeHandler in conference application * Use the specified IP address both for signaling and media * Allow user-part only matching on playback application * Don't manually create the Contact header when not needed * Fix JingleSession and adapt audio streams to API changes * Strip HTML in IRCconference application * Fix handling XMPP stanzas sent to a bare JID when the session was bound -- Saul Ibarra Fri, 05 Dec 2014 13:21:10 +0100 sylkserver (2.7.2) unstable; urgency=medium * Fix setting local IP address * Null doesn't need to be instantiated -- Saul Ibarra Tue, 12 Aug 2014 14:34:21 +0200 sylkserver (2.7.1) unstable; urgency=medium * Fix variable name -- Saul Ibarra Fri, 18 Jul 2014 13:07:13 +0200 sylkserver (2.7.0) unstable; urgency=medium * Added setting to set the hostname for conference room screen sharing URL * Fix race condition when initializing TLS transport * Fixed streams API usage after changes in SIPSIMPLE SDK * Fix handling cancelled proposals * Added display_name attribute to conference rooms * Simplify loading room configuration * Cleanup old room files on startup * Use '.log' as the extension for log files * Added logrotate file * Raised python-sipsimple version dependency -- Saul Ibarra Wed, 09 Jul 2014 16:12:45 +0200 sylkserver (2.6.2) unstable; urgency=medium * Fixed resource leak in playback application * Refactored welcome prompt playback for ircconference application -- Saul Ibarra Thu, 12 Jun 2014 09:26:23 +0200 sylkserver (2.6.1) unstable; urgency=medium * Adjust Session to changes in SIP SIMPLE SDK * Adapted server startup to changes in SIP SIMPLE SDK * Send REPORT chunks automatically for keep-alive chunks * Log SIP SIMPLE SDK version * Raised python-sipsimple version dependency -- Saul Ibarra Wed, 21 May 2014 15:13:43 +0200 sylkserver (2.6.0) unstable; urgency=medium * Fixed issues when shutting down the Engine * Fixed generating is-composing payload when refresh is not set * Accept multiple PSTN numbers for a given conference room * Use better API for building is-composing payload * Avoid unnecessary processing when dealing with CPIMIdentity objects * Simplified history storage in conference app * Simplified code for handling proposal failures * Simplified code for starting/stopping SylkServer * Renamed incoming_sip_message to incoming_message * Use the new NetworkConditionsDidChange notification * Bumped Debian Standards-Version * Raised python-sipsimple version dependency -- Saul Ibarra Wed, 19 Feb 2014 16:54:21 +0100 sylkserver (2.5.1) unstable; urgency=medium * Adapted to API changes in SIP SIMPLE SDK * Added option to dump core in case of a crash * Fixed dispatching messages when in bonjour mode * Limit session in echo application to 10 minutes * Skip broadcasting OTR messages * Reworked server stop mechanism * Removed obsolete sound files and fixed co_there_is prompt * Fixed removing observer if notification is processed too late -- Saul Ibarra Mon, 16 Dec 2013 16:36:07 +0100 sylkserver (2.5.0) unstable; urgency=low * Adapted to changes in latest SIP SIMPLE SDK * Added playback application * Enabled Opus codec by default * Added setting for sample rate, defaults to 32 kHz * Advertise PSTN and XMPP access in conference rooms * Replaced prompts with higher quality ones * Fixed initializing PJSIP's internal resolver * Don't use signal.pause to pause the main thread * Always disable echo canceller * Improved logging * Ignore audio device change notifications * Removed dependency on python-backports * Dropped Python 2.6 support -- Saul Ibarra Fri, 09 Aug 2013 13:15:53 +0200 sylkserver (2.4.1) unstable; urgency=low * Allow Jingle users to join conference rooms with audio * Added support for XEP-298 (coin) * Improved logging for incoming sessions * Fixed sending JingleSessionDidStart too early * Prevent real RTP from leaking until we get session-accept * Partial nickname support for IRC conference application * Set version attribute on conference-info payloads -- Saul Ibarra Fri, 05 Apr 2013 12:29:51 +0200 sylkserver (2.4.0) unstable; urgency=low * Added VoIP translation for SIP/XMPP gateway (Jingle) * Added Presence to Bonjour conference rooms (XEP-0174) * Added support for XMPP software version (XEP-0092) * Added support for XMPP ping (XEP-0199) * Reply with service-unavailable to unsupported XMPP IQ stanzas * Improved XMPP service discovery support (XEP-0115) * Fixed a race condition related to SIP subscriptions * Improved description of XMPP related settings -- Saul Ibarra Fri, 22 Mar 2013 14:02:28 +0100 sylkserver (2.3.0) unstable; urgency=low * Added SIP/XMPP gateway ability to invite participants to a multiparty chat * Added RTP audio and MSRP chat 'echo' application * Added support for XEP-0030 (service discovery) * Added ability to load extra applications from an external directory * Added timestamp to generated PIDF documents * Simplified mechanism required for adding new applications * Refactored per-application logger * Improved logging in XMPP gateway and conference applications * Removed extended-away state handling as it no longer exists in the SDK * Made several improvements to XMPP stanza parsing * Fixed detecting MSRP Nickname collision * Fixed handling presence stanzas without a resource part in the from * Fixed translating resource IDs for presence * Fixed leaking session objects if session fails while joining a conference * Fixed mapping room URI for received REFER requests -- Saul Ibarra Wed, 30 Jan 2013 11:04:10 +0100 sylkserver (2.2.1) unstable; urgency=low * Fixed stream creation after API changes in SDK * Fixed accessing session objects after API changes in SDK * Renamed ServerSession to Session -- Saul Ibarra Fri, 09 Nov 2012 15:55:19 +0100 sylkserver (2.2.0) unstable; urgency=low * Added XEP-0115 caps to presence stanzas * Publish service capabilities for PIDFs translated from XMPP * Adapted to changes in streams API * Use 'xmpp' URI parameter as a hint on generated PIDFs * Fixed message dispatching if non matching identities are found * Fixed initializing conference application database * Fixed not terminating incoming referral in certain cases -- Saul Ibarra Fri, 26 Oct 2012 18:44:44 +0200 sylkserver (2.1.1) unstable; urgency=low * Fixed file transfers when using Bonjour mode * Fixed normalizing IPAddress datatype * Disables private messages support when using Bonjour mode -- Saul Ibarra Tue, 09 Oct 2012 14:14:52 +0200 sylkserver (2.1.0) unstable; urgency=low * Added ability to disable applications * Added ability to configure the directory for resource files * Added ability to listen on all interfaces * Refactored Bonjour support * Fixed starting music on hold playback * Fixed setting extended status for XMPP dnd state * Fixed API calls due to changes in SIP SIMPLE SDK * Delay conference database initialization until application is started * Don't encode and quote DeviceInfo description -- Saul Ibarra Mon, 17 Sep 2012 11:22:59 +0200 sylkserver (2.0.0) unstable; urgency=low * Added XMPP gateway application * Added Bonjour support * Added support for MSRP NICKNAME * Added ability to map applications by RURI, domain or username * Added ability to select desired application with X-Sylk-App header * Added ApplicationLogger, in order to prefix each application's log lines * Added start/stop methods to applications * Added ability to specify more attributes when sending MSRP messages * Allow applications to handle the 'presence' event on incoming subscriptions * Patch sipsimple.session to use ServerSession objects * Modified ChatStream to send MSRP REPORT chunks manually * Made contact_header optional in ServerSession.connect * Use received reason when notifyig about REFER request progress -- Saul Ibarra Thu, 06 Sep 2012 21:38:03 +0200 sylkserver (1.3.0) unstable; urgency=low * Added multiparty comference screen sharing capability by accepting jpeg images over an established MSRP chat stream * Added web-server to serve shared screens * Made configuration file optional by using defaults settings * Initialize applications after loading them * Fixed parsing Refer-To URI * Pass-through additional headers when dispatching chat messages * Adjusted to the latest changes in XML payloads from sipsimple * Reject incoming call transfer requests to conference rooms * Dropped support for Python 2.5 -- Saul Ibarra Thu, 22 Dec 2011 10:08:02 +0100 sylkserver (1.2.3) unstable; urgency=low * Adapted to API changes in SIPSIMPLE SDK -- Saul Ibarra Tue, 20 Sep 2011 10:19:48 +0200 sylkserver (1.2.2) unstable; urgency=low * Enabled TLS by default * Fixed compatibility with Python 2.5 * Fixed regression when sending private messages * Fixed renaming file when it already exists * Listen by default on port 5061 for SIP TLS transport * Fixed exception when proposal is rejected but no timer was added * Adapted to accounts handling changes in the middleware -- Saul Ibarra Wed, 20 Jul 2011 17:07:24 +0200 sylkserver (1.2.1) unstable; urgency=low * Handle case when session is ended while a proposal is pending * Allow SylkServer to be built with Python 2.5 * Reworked Debian packaging -- Saul Ibarra Tue, 07 Jun 2011 14:20:13 +0200 sylkserver (1.2.0) unstable; urgency=low * Added file transfer support * Added support for MSRP ACM * Terminate all pending subscriptions when stopping a room * Fixed outbound IP address on MSRP streams * Fixed memory leaks -- Saul Ibarra Thu, 19 May 2011 15:51:46 +0200 sylkserver (1.1.0) unstable; urgency=low * Added incoming REFER support * Added outgoing INVITE support * Added SIP outbound proxy support * Added Trusted Peers based on source IP address * Added Access Control Lists support to conference application * Added basic multi-application support * Added IRC conference application * Added SIPThor integration * Fixed initialization of TLS settings * Made session connect method receive the contact header * Catch exception if outgoing NOTIFY could not be sent * Fixed exception when sending private message to a participant without chat * Refactored exception handling when sending chat messages * Refactored application finding mechanism * Reject incoming requests with 404 if application is not found * Removed SIP MESSAGE support in conference application -- Saul Ibarra Fri, 18 Mar 2011 16:43:37 +0100 sylkserver (1.0.1) unstable; urgency=low * Added unicode support * Fixed building CPIMIdentity object * Use request URI to match rooms instead of the To header -- Saul Ibarra Thu, 17 Feb 2011 10:45:21 +0100 sylkserver (1.0.0) unstable; urgency=low * Initial release -- Saul Ibarra Thu, 27 Jan 2011 17:43:11 +0100 diff --git a/debian/control b/debian/control index 9feab95..6d26d30 100644 --- a/debian/control +++ b/debian/control @@ -1,49 +1,48 @@ Source: sylkserver Section: net Priority: optional -Maintainer: Dan Pascu -Uploaders: Adrian Georgescu -Build-Depends: debhelper (>= 11), dh-python, python-all (>= 2.7), rename +Maintainer: Adrian Georgescu +Uploaders: Tijmen de Mes +Build-Depends: debhelper (>= 11), dh-python, python3, rename Standards-Version: 4.5.0 Package: sylkserver Architecture: all -Depends: ${python:Depends}, ${misc:Depends}, +Depends: ${python3:Depends}, ${misc:Depends}, lsb-base, - python-application (>= 2.8.0), - python-autobahn, - python-eventlib, - python-klein, - python-lxml, - python-sipsimple (>= 3.5.0), - python-systemd, - python-twisted, - python-typing -Suggests: libavahi-compat-libdnssd1, python-wokkel, sylkserver-webrtc-gateway + python3-application, + python3-autobahn, + python3-eventlib, + python3-klein, + python3-lxml, + python3-sipsimple, + python3-systemd, + python3-twisted +Suggests: libavahi-compat-libdnssd1, python3-wokkel, sylkserver-webrtc-gateway Recommends: sylkserver-sounds Description: Extensible real-time-communications application server SylkServer is a SIP applications server that provides applications like echo, playback and conference, as well as act as a gateway between SIP and IRC, XMPP and WEBRTC. Package: sylkserver-sounds Architecture: all Depends: ${misc:Depends} Description: Extensible real-time-communications application server sounds SylkServer is a SIP applications server that provides applications like echo, playback and conference, as well as act as a gateway between SIP and IRC, XMPP and WEBRTC. . This package contains sounds used by SylkServer. Package: sylkserver-webrtc-gateway Architecture: all Depends: ${misc:Depends}, sylkserver, janus -Recommends: python-cassandra (>=3.7.1-2.1) +Recommends: python3-cassandra Description: Extensible real-time-communications application server WebRTC gateway SylkServer is a SIP applications server that provides applications like echo, playback and conference, as well as act as a gateway between SIP and IRC, XMPP and WEBRTC. . This is a meta-package containing the dependencies required to run the WebRTC gateway application. diff --git a/debian/rules b/debian/rules index 0ce4e5d..f787f1c 100755 --- a/debian/rules +++ b/debian/rules @@ -1,16 +1,16 @@ #!/usr/bin/make -f %: - dh $@ --with python2 --buildsystem=pybuild + dh $@ --with python3 --buildsystem=pybuild override_dh_install: # Move samples to configuration files in the debian package rename 's/\.sample$$//' debian/tmp/etc/sylkserver/*.sample # Remove spaces in sound file names as they end up corrupted in the deb rename 's/ //g' debian/tmp/usr/share/sylkserver/sounds/moh/*.wav dh_install override_dh_clean: dh_clean rm -rf build dist MANIFEST diff --git a/makedeb.sh b/makedeb.sh new file mode 100755 index 0000000..8e39b52 --- /dev/null +++ b/makedeb.sh @@ -0,0 +1,17 @@ +#!/bin/bash +if [ -f dist ]; then + rm -r dist +fi + +python3 setup.py sdist + +cd dist +tar zxvf *.tar.gz + +cd sylkserver-?.?.? + +debuild --no-sign + +cd .. + +ls diff --git a/setup.py b/setup.py index 0c0897d..1b69eba 100755 --- a/setup.py +++ b/setup.py @@ -1,43 +1,43 @@ -#!/usr/bin/python2 +#!/usr/bin/python3 from distutils.core import setup import glob import os import sylk def find_packages(root): return [directory.replace(os.path.sep, '.') for directory, sub_dirs, files in os.walk(root) if '__init__.py' in files] def list_resources(source_directory, destination_directory): return [(directory.replace(source_directory, destination_directory), [os.path.join(directory, f) for f in files]) for directory, sub_dirs, files in os.walk(source_directory)] setup( name='sylkserver', version=sylk.__version__, description='SylkServer - An Extensible RTC Application Server', url='http://sylkserver.com/', author='AG Projects', author_email='support@ag-projects.com', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Service Providers', 'License :: GNU General Public License 3', 'Operating System :: OS Independent', 'Programming Language :: Python' ], requires=[], packages=find_packages('sylk'), scripts=['sylk-server'], data_files=[('/etc/sylkserver', glob.glob('*.ini.sample')), ('/etc/sylkserver/tls', glob.glob('resources/tls/*.crt'))] + list_resources('resources', destination_directory='share/sylkserver') ) diff --git a/sylk-server b/sylk-server index d7611a9..0034b97 100755 --- a/sylk-server +++ b/sylk-server @@ -1,118 +1,118 @@ -#!/usr/bin/python2 +#!/usr/bin/env python3 import os import signal import sys from application import log from application.process import process, ProcessError from argparse import ArgumentParser import sipsimple import sylk # noinspection PyUnusedLocal def stop_server(signum, frame): sylk_server = SylkServer() sylk_server.stop() # noinspection PyUnusedLocal def toggle_debugging(signum, frame): if log.level.current != log.level.DEBUG: log.level.current = log.level.DEBUG log.info('Switched logging level to DEBUG') else: log.info('Switched logging level to {}'.format(ServerConfig.log_level)) log.level.current = ServerConfig.log_level # noinspection PyUnusedLocal def dump_observers(signum, frame): from application.notification import NotificationCenter from pprint import pprint notification_center = NotificationCenter() pprint(notification_center.observers) if __name__ == '__main__': name = 'sylk-server' fullname = 'SylkServer' process.configuration.subdirectory = 'sylkserver' process.runtime.subdirectory = 'sylkserver' parser = ArgumentParser(usage='%(prog)s [options]') parser.add_argument('--version', action='version', version='%(prog)s {}'.format(sylk.__version__)) parser.add_argument('--systemd', action='store_true', help='run as a systemd simple service and log to journal') parser.add_argument('--no-fork', action='store_false', dest='fork', help='run in the foreground and log to the terminal') parser.add_argument('--config-dir', dest='config_directory', default=None, help='the configuration directory', metavar='PATH') parser.add_argument('--runtime-dir', dest='runtime_directory', default=None, help='the runtime directory ({})'.format(process.runtime.directory), metavar='PATH') parser.add_argument('--enable-bonjour', action='store_true', help='enable Bonjour services') parser.add_argument('--debug', action='store_true', help='enable verbose logging') parser.add_argument('--debug-memory', action='store_true', help='enable memory debugging') options = parser.parse_args() if options.config_directory is not None: process.configuration.local_directory = options.config_directory if options.runtime_directory is not None: process.runtime.directory = options.runtime_directory if options.systemd: from systemd.journal import JournalHandler log.set_handler(JournalHandler(SYSLOG_IDENTIFIER=name)) log.capture_output() elif options.fork: sys.argv[0] = os.path.realpath(sys.argv[0]) # on fork the current directory changes to / resulting in the wrong resources directory if started with a relative path try: process.daemonize(pidfile='{}.pid'.format(name)) except ProcessError as e: log.fatal('Failed to start {name}: {exception!s}'.format(name=fullname, exception=e)) sys.exit(1) log.use_syslog(name) from sylk.resources import Resources from sylk.server import SylkServer, ServerConfig log.info('Starting {name} {sylk.__version__}, using SIP SIMPLE SDK {sipsimple.__version__}'.format(name=fullname, sylk=sylk, sipsimple=sipsimple)) configuration = ServerConfig.__cfgtype__(ServerConfig.__cfgfile__) if configuration.files: log.info('Reading configuration from {}'.format(', '.join(configuration.files))) else: log.info('Not reading any configuration files (using internal defaults)') log.info('Using resources from {}'.format(Resources.directory)) if options.debug: log.level.current = log.level.DEBUG if options.debug_memory: from application.debug.memory import memory_dump process.signals.add_handler(signal.SIGTERM, stop_server) process.signals.add_handler(signal.SIGINT, stop_server) process.signals.add_handler(signal.SIGUSR1, toggle_debugging) process.signals.add_handler(signal.SIGUSR2, dump_observers) server = SylkServer() try: server.run(options) except Exception as e: log.fatal('Failed to start {name}: {exception!s}'.format(name=fullname, exception=e)) log.exception() sys.exit(1) finally: if options.debug_memory: memory_dump() # the run() method returns after the server is stopped if server.state == 'stopped': log.info('{name} stopped'.format(name=fullname)) sys.exit(int(server.failed)) else: log.info('Forcefully exiting {name}...'.format(name=fullname)) # noinspection PyProtectedMember os._exit(1) diff --git a/sylk/__init__.py b/sylk/__init__.py index 939163d..ae57b84 100644 --- a/sylk/__init__.py +++ b/sylk/__init__.py @@ -1,2 +1,2 @@ -__version__ = '5.8.0' +__version__ = '6.0.0' diff --git a/sylk/accounts.py b/sylk/accounts.py index 01054ff..457989c 100644 --- a/sylk/accounts.py +++ b/sylk/accounts.py @@ -1,75 +1,75 @@ from application.system import host from sipsimple.account import Account, AccountManager from sipsimple.configuration import SettingsObject from sipsimple.configuration.datatypes import SIPAddress from sipsimple.core import Engine, Route, SIPURI from sylk.configuration import SIPConfig __all__ = 'DefaultAccount', class DefaultContactURIFactory(object): def __init__(self): self.username = 'sylkserver' def __getitem__(self, key): if isinstance(key, tuple): # The first part of the key might be PublicGRUU and so on, but we don't care about # those here, so ignore them _, key = key - if not isinstance(key, (basestring, Route)): + if not isinstance(key, (str, Route)): raise KeyError("key must be a transport name or Route instance") - transport = key if isinstance(key, basestring) else key.transport + transport = key if isinstance(key, str) else key.transport parameters = {} if transport=='udp' else {'transport': transport} if SIPConfig.local_ip not in (None, '0.0.0.0'): ip = SIPConfig.local_ip.normalized - elif isinstance(key, basestring): + elif isinstance(key, str): ip = host.default_ip else: ip = host.outgoing_ip_for(key.address) if ip is None: raise KeyError("could not get outgoing IP address") port = getattr(Engine(), '%s_port' % transport, None) if port is None: raise KeyError("unsupported transport: %s" % transport) uri = SIPURI(user=self.username, host=ip, port=port) uri.parameters.update(parameters) return uri class DefaultAccount(Account): """ Subclass of Account which doesn't start any subsystem. SylkServer just uses it as the default account for all applications as a settings object. """ __id__ = SIPAddress('default@sylkserver') id = property(lambda self: self.__id__) enabled = True def __new__(cls): - with AccountManager.load.lock: - if not AccountManager.load.called: - raise RuntimeError("cannot instantiate %s before calling AccountManager.load" % cls.__name__) - return SettingsObject.__new__(cls) + #with AccountManager.load.lock: + # if not AccountManager.load.called: + # raise RuntimeError("cannot instantiate %s before calling AccountManager.load" % cls.__name__) + return SettingsObject.__new__(cls) def __init__(self): super(DefaultAccount, self).__init__('default@sylkserver') self.contact = DefaultContactURIFactory() @property def uri(self): return SIPURI(user='sylkserver', host=SIPConfig.local_ip.normalized) def _activate(self): pass def _deactivate(self): pass diff --git a/sylk/applications/__init__.py b/sylk/applications/__init__.py index d5f2cac..5e7c22d 100644 --- a/sylk/applications/__init__.py +++ b/sylk/applications/__init__.py @@ -1,331 +1,325 @@ import abc import imp import logging import os import socket import struct import sys from application import log from application.configuration.datatypes import NetworkRange from application.notification import IObserver, NotificationCenter from application.python import Null from application.python.decorator import execute_once from application.python.types import Singleton from collections import defaultdict from itertools import chain from sipsimple.threading import run_in_twisted_thread -from zope.interface import implements +from zope.interface import implementer from sylk.configuration import ServerConfig, SIPConfig, ThorNodeConfig __all__ = 'ISylkApplication', 'ApplicationRegistry', 'SylkApplication', 'IncomingRequestHandler', 'ApplicationLogger' SYLK_APP_HEADER = 'X-Sylk-App' def find_builtin_applications(): applications_directory = os.path.dirname(__file__) for path, dirs, files in os.walk(applications_directory): parent_directory, name = os.path.split(path) if parent_directory == applications_directory and '__init__.py' in files and name not in ServerConfig.disabled_applications: yield name if path != applications_directory: del dirs[:] # do not descend more than 1 level def find_extra_applications(): if ServerConfig.extra_applications_dir: applications_directory = os.path.realpath(ServerConfig.extra_applications_dir.normalized) for path, dirs, files in os.walk(applications_directory): parent_directory, name = os.path.split(path) if parent_directory == applications_directory and '__init__.py' in files and name not in ServerConfig.disabled_applications: yield name if path != applications_directory: del dirs[:] # do not descend more than 1 level def find_applications(): return chain(find_builtin_applications(), find_extra_applications()) -class ApplicationRegistry(object): - __metaclass__ = Singleton - +class ApplicationRegistry(object, metaclass=Singleton): def __init__(self): self.application_map = {} def __getitem__(self, name): return self.application_map[name] def __contains__(self, name): return name in self.application_map def __iter__(self): - return iter(self.application_map.values()) + return iter(list(self.application_map.values())) def __len__(self): return len(self.application_map) - @execute_once + #@execute_once def load_applications(self): for name in find_builtin_applications(): try: __import__('sylk.applications.{name}'.format(name=name)) except ImportError as e: log.error('Failed to load builtin application {name!r}: {exception!s}'.format(name=name, exception=e)) for name in find_extra_applications(): if name in sys.modules: # being able to log this is contingent on this function only executing once log.warning('Not loading extra application {name!r} as it would overshadow a system package/module'.format(name=name)) continue try: imp.load_module(name, *imp.find_module(name, [ServerConfig.extra_applications_dir.normalized])) except ImportError as e: log.error('Failed to load extra application {name!r}: {exception!s}'.format(name=name, exception=e)) def add(self, app_class): try: app = app_class() except Exception as e: log.exception('Failed to initialize {app.__appname__!r} application: {exception!s}'.format(app=app_class, exception=e)) else: self.application_map[app.__appname__] = app def get(self, name, default=None): return self.application_map.get(name, default) class ApplicationName(object): def __get__(self, instance, instance_type): name = instance_type.__name__ return name[:-11].lower() if name.endswith('Application') else name.lower() class SylkApplicationMeta(abc.ABCMeta, Singleton): """Metaclass for defining SylkServer applications: a Singleton that also adds them to the application registry""" def __init__(cls, name, bases, dic): super(SylkApplicationMeta, cls).__init__(name, bases, dic) if name != 'SylkApplication': ApplicationRegistry().add(cls) -class SylkApplication(object): +class SylkApplication(object, metaclass=SylkApplicationMeta): """Base class for all SylkServer applications""" - - __metaclass__ = SylkApplicationMeta __appname__ = ApplicationName() @abc.abstractmethod def start(self): pass @abc.abstractmethod def stop(self): pass @abc.abstractmethod def incoming_session(self, session): pass @abc.abstractmethod def incoming_subscription(self, subscribe_request, data): pass @abc.abstractmethod def incoming_referral(self, refer_request, data): pass @abc.abstractmethod def incoming_message(self, message_request, data): pass class ApplicationNotLoadedError(Exception): pass -class IncomingRequestHandler(object): +@implementer(IObserver) +class IncomingRequestHandler(object, metaclass=Singleton): """Handle incoming requests and match them to applications""" - __metaclass__ = Singleton - implements(IObserver) - def __init__(self): self.application_registry = ApplicationRegistry() self.application_registry.load_applications() log.info('Loaded applications: {}'.format(', '.join(sorted(app.__appname__ for app in self.application_registry)))) if ServerConfig.default_application not in self.application_registry: log.warning('Default application "%s" does not exist, falling back to "conference"' % ServerConfig.default_application) ServerConfig.default_application = 'conference' else: log.info('Default application: %s' % ServerConfig.default_application) self.application_map = dict((item.split(':')) for item in ServerConfig.application_map) if self.application_map: txt = 'Application map:\n' inverted_app_map = defaultdict(list) - for url, app in self.application_map.iteritems(): + for url, app in self.application_map.items(): inverted_app_map[app].append(url) - for app, urls in inverted_app_map.iteritems(): + for app, urls in inverted_app_map.items(): txt += ' {}: {}\n'.format(app, ', '.join(urls)) log.info(txt[:-1]) self.authorization_handler = AuthorizationHandler() def start(self): for app in self.application_registry: try: app.start() except Exception as e: log.exception('Failed to start {app.__appname__!r} application: {exception!s}'.format(app=app, exception=e)) self.authorization_handler.start() notification_center = NotificationCenter() notification_center.add_observer(self, name='SIPSessionNewIncoming') notification_center.add_observer(self, name='SIPIncomingSubscriptionGotSubscribe') notification_center.add_observer(self, name='SIPIncomingReferralGotRefer') notification_center.add_observer(self, name='SIPIncomingRequestGotRequest') def stop(self): self.authorization_handler.stop() notification_center = NotificationCenter() notification_center.remove_observer(self, name='SIPSessionNewIncoming') notification_center.remove_observer(self, name='SIPIncomingSubscriptionGotSubscribe') notification_center.remove_observer(self, name='SIPIncomingReferralGotRefer') notification_center.remove_observer(self, name='SIPIncomingRequestGotRequest') for app in self.application_registry: try: app.stop() except Exception as e: log.exception('Failed to stop {app.__appname__!r} application: {exception!s}'.format(app=app, exception=e)) def get_application(self, ruri, headers): if SYLK_APP_HEADER in headers: application_name = headers[SYLK_APP_HEADER].body.strip() else: application_name = ServerConfig.default_application if self.application_map: prefixes = ("%s@%s" % (ruri.user, ruri.host), ruri.host, ruri.user) for prefix in prefixes: if prefix in self.application_map: application_name = self.application_map[prefix] break try: return self.application_registry[application_name] except KeyError: log.error('Application %s is not loaded' % application_name) raise ApplicationNotLoadedError @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionNewIncoming(self, notification): session = notification.sender try: self.authorization_handler.authorize_source(session.peer_address.ip) except UnauthorizedRequest: session.reject(403) return try: app = self.get_application(session.request_uri, notification.data.headers) except ApplicationNotLoadedError: session.reject(404) else: app.incoming_session(session) def _NH_SIPIncomingSubscriptionGotSubscribe(self, notification): subscribe_request = notification.sender try: self.authorization_handler.authorize_source(subscribe_request.peer_address.ip) except UnauthorizedRequest: subscribe_request.reject(403) return try: app = self.get_application(notification.data.request_uri, notification.data.headers) except ApplicationNotLoadedError: subscribe_request.reject(404) else: app.incoming_subscription(subscribe_request, notification.data) def _NH_SIPIncomingReferralGotRefer(self, notification): refer_request = notification.sender try: self.authorization_handler.authorize_source(refer_request.peer_address.ip) except UnauthorizedRequest: refer_request.reject(403) return try: app = self.get_application(notification.data.request_uri, notification.data.headers) except ApplicationNotLoadedError: refer_request.reject(404) else: app.incoming_referral(refer_request, notification.data) def _NH_SIPIncomingRequestGotRequest(self, notification): request = notification.sender if notification.data.method != 'MESSAGE': request.answer(405) return try: self.authorization_handler.authorize_source(request.peer_address.ip) except UnauthorizedRequest: request.answer(403) return try: app = self.get_application(notification.data.request_uri, notification.data.headers) except ApplicationNotLoadedError: request.answer(404) else: app.incoming_message(request, notification.data) class UnauthorizedRequest(Exception): pass +@implementer(IObserver) class AuthorizationHandler(object): - implements(IObserver) def __init__(self): self.state = None self.trusted_peers = SIPConfig.trusted_peers self.thor_nodes = [] @property def trusted_parties(self): if ThorNodeConfig.enabled: return self.thor_nodes return self.trusted_peers def start(self): NotificationCenter().add_observer(self, name='ThorNetworkGotUpdate') self.state = 'started' def stop(self): self.state = 'stopped' NotificationCenter().remove_observer(self, name='ThorNetworkGotUpdate') def authorize_source(self, ip_address): if self.state != 'started': raise UnauthorizedRequest for range in self.trusted_parties: - if struct.unpack('!L', socket.inet_aton(ip_address))[0] & range[1] == range[0]: + if struct.unpack('!L', socket.inet_aton(ip_address.decode()))[0] & range[1] == range[0]: return True raise UnauthorizedRequest @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_ThorNetworkGotUpdate(self, notification): - self.thor_nodes = [NetworkRange(node) for node in chain.from_iterable(n.nodes for n in notification.data.networks.values())] + self.thor_nodes = [NetworkRange(node.decode()) for node in chain.from_iterable(n.nodes for n in list(notification.data.networks.values()))] class ApplicationLogger(object): def __new__(cls, package): return logging.getLogger(package.split('.')[-1]) diff --git a/sylk/applications/conference/__init__.py b/sylk/applications/conference/__init__.py index 264205a..620f08e 100644 --- a/sylk/applications/conference/__init__.py +++ b/sylk/applications/conference/__init__.py @@ -1,411 +1,413 @@ import os import re import shutil from application.notification import IObserver, NotificationCenter from application.python import Null from sipsimple.account.bonjour import BonjourPresenceState from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import SIPURI, SIPCoreError from sipsimple.core import Header, FromHeader, ToHeader, SubjectHeader from sipsimple.lookup import DNSLookup from sipsimple.streams import MediaStreamRegistry from sipsimple.threading.green import run_in_green_thread from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.applications import SylkApplication from sylk.applications.conference.configuration import get_room_config, ConferenceConfig from sylk.applications.conference.logger import log from sylk.applications.conference.room import Room from sylk.applications.conference.web import ConferenceWeb from sylk.bonjour import BonjourService from sylk.configuration import ServerConfig, ThorNodeConfig from sylk.session import Session, IllegalStateError from sylk.web import server as web_server class ACLValidationError(Exception): pass class RoomNotFoundError(Exception): pass +@implementer(IObserver) class ConferenceApplication(SylkApplication): - implements(IObserver) def __init__(self): self._rooms = {} self.invited_participants_map = {} self.bonjour_focus_service = Null self.bonjour_room_service = Null self.web = Null def start(self): self.web = ConferenceWeb(self) web_server.register_resource('conference', self.web.resource) # cleanup old files for path in (ConferenceConfig.file_transfer_dir, ConferenceConfig.screensharing_images_dir): try: shutil.rmtree(path) except EnvironmentError: pass if ServerConfig.enable_bonjour and ServerConfig.default_application == 'conference': self.bonjour_focus_service = BonjourService(service='sipfocus') self.bonjour_focus_service.start() log.info("Bonjour publication started for service 'sipfocus'") self.bonjour_room_service = BonjourService(service='sipuri', name='Conference Room', uri_user='conference') self.bonjour_room_service.start() - self.bonjour_room_service.presence_state = BonjourPresenceState('available', u'No participants') + self.bonjour_room_service.presence_state = BonjourPresenceState('available', 'No participants') log.info("Bonjour publication started for service 'sipuri'") def stop(self): self.bonjour_focus_service.stop() self.bonjour_room_service.stop() def get_room(self, uri, create=False): room_uri = '%s@%s' % (uri.user, uri.host) try: room = self._rooms[room_uri] except KeyError: if create: room = Room(room_uri) self._rooms[room_uri] = room return room else: raise RoomNotFoundError else: return room def remove_room(self, uri): room_uri = '%s@%s' % (uri.user, uri.host) self._rooms.pop(room_uri, None) def validate_acl(self, room_uri, from_uri): room_uri = '%s@%s' % (room_uri.user, room_uri.host) cfg = get_room_config(room_uri) if cfg.access_policy == 'allow,deny': if cfg.allow.match(from_uri) and not cfg.deny.match(from_uri): return raise ACLValidationError else: if cfg.deny.match(from_uri) and not cfg.allow.match(from_uri): raise ACLValidationError def incoming_session(self, session): - log.info('New session from %s to %s' % (session.remote_identity.uri, session.local_identity.uri)) + peer = '%s:%s' % (session.transport, session.peer_address) + log.info('Session %s from %s: %s -> %s' % (session.call_id, peer, session.remote_identity.uri, session.local_identity.uri)) audio_streams = [stream for stream in session.proposed_streams if stream.type=='audio'] chat_streams = [stream for stream in session.proposed_streams if stream.type=='chat'] transfer_streams = [stream for stream in session.proposed_streams if stream.type=='file-transfer'] if not audio_streams and not chat_streams and not transfer_streams: log.info(u'Session rejected: invalid media') session.reject(488) return audio_stream = audio_streams[0] if audio_streams else None chat_stream = chat_streams[0] if chat_streams else None transfer_stream = transfer_streams[0] if transfer_streams else None try: self.validate_acl(session.request_uri, session.remote_identity.uri) except ACLValidationError: - log.info(u'Session rejected: unauthorized by access list') + log.info('Session rejected: unauthorized by access list') session.reject(403) return if transfer_stream is not None: try: room = self.get_room(session.request_uri) except RoomNotFoundError: - log.info(u'Session rejected: room not found') + log.info('Session rejected: room not found') session.reject(404) return if transfer_stream.direction == 'sendonly': # file transfer 'pull' try: file = next(file for file in room.files if file.hash == transfer_stream.file_selector.hash) except StopIteration: - log.info(u'Session rejected: requested file not found') + log.info('Session rejected: requested file not found') session.reject(404) return try: transfer_stream.file_selector = file.file_selector except EnvironmentError as e: - log.info(u'Session rejected: error opening requested file: %s' % e) + log.info('Session rejected: error opening requested file: %s' % e) session.reject(404) return else: transfer_stream.handler.save_directory = os.path.join(ConferenceConfig.file_transfer_dir.normalized, room.uri) NotificationCenter().add_observer(self, sender=session) if audio_stream: session.send_ring_indication() streams = [stream for stream in (audio_stream, chat_stream, transfer_stream) if stream] reactor.callLater(4 if audio_stream is not None else 0, self.accept_session, session, streams) def incoming_subscription(self, subscribe_request, data): from_header = data.headers.get('From', Null) to_header = data.headers.get('To', Null) if Null in (from_header, to_header): subscribe_request.reject(400) return - if subscribe_request.event != 'conference': - log.info(u'Subscription for event %s rejected: only conference event is supported' % subscribe_request.event) + if subscribe_request.event != b'conference': + log.info('Subscription for event %s rejected: only conference event is supported' % subscribe_request.event) subscribe_request.reject(489) return try: self.validate_acl(data.request_uri, from_header.uri) except ACLValidationError: try: self.validate_acl(to_header.uri, from_header.uri) except ACLValidationError: # Check if we need to skip the ACL because this was an invited participant if not (str(from_header.uri) in self.invited_participants_map.get('%s@%s' % (data.request_uri.user, data.request_uri.host), {}) or str(from_header.uri) in self.invited_participants_map.get('%s@%s' % (to_header.uri.user, to_header.uri.host), {})): - log.info(u'Subscription rejected: unauthorized by access list') + log.info('Subscription rejected: unauthorized by access list') subscribe_request.reject(403) return try: room = self.get_room(data.request_uri) except RoomNotFoundError: try: room = self.get_room(to_header.uri) except RoomNotFoundError: - log.info(u'Subscription rejected: room not yet created') + log.info('Subscription rejected: room not yet created') subscribe_request.reject(480) return if not room.started: - log.info(u'Subscription rejected: room not started yet') + log.info('Subscription rejected: room not started yet') subscribe_request.reject(480) else: room.handle_incoming_subscription(subscribe_request, data) def incoming_referral(self, refer_request, data): from_header = data.headers.get('From', Null) to_header = data.headers.get('To', Null) refer_to_header = data.headers.get('Refer-To', Null) if Null in (from_header, to_header, refer_to_header): refer_request.reject(400) return - log.info(u'Room %s - join request from %s to %s' % ('%s@%s' % (to_header.uri.user, to_header.uri.host), from_header.uri, refer_to_header.uri)) + log.info('Room %s - join request from %s to %s' % ('%s@%s' % (to_header.uri.user, to_header.uri.host), from_header.uri, refer_to_header.uri)) try: self.validate_acl(data.request_uri, from_header.uri) except ACLValidationError: - log.info(u'Room %s - invite participant request rejected: unauthorized by access list' % data.request_uri) + log.info('Room %s - invite participant request rejected: unauthorized by access list' % data.request_uri) refer_request.reject(403) return referral_handler = IncomingReferralHandler(refer_request, data) referral_handler.start() def incoming_message(self, message_request, data): - log.info(u'SIP MESSAGE is not supported, use MSRP media instead') + log.info('SIP MESSAGE is not supported, use MSRP media instead') message_request.answer(405) def accept_session(self, session, streams): if session.state == 'incoming': try: session.accept(streams, is_focus=True) except IllegalStateError: pass def add_participant(self, session, room_uri): # Keep track of the invited participants, we must skip ACL policy # for SUBSCRIBE requests room_uri_str = '%s@%s' % (room_uri.user, room_uri.host) - log.info(u'Room %s - outgoing session to %s started' % (room_uri_str, session.remote_identity.uri)) + log.info('Room %s - outgoing session to %s started' % (room_uri_str, session.remote_identity.uri)) d = self.invited_participants_map.setdefault(room_uri_str, {}) d.setdefault(str(session.remote_identity.uri), 0) d[str(session.remote_identity.uri)] += 1 NotificationCenter().add_observer(self, sender=session) room = self.get_room(room_uri, True) room.start() room.add_session(session) def remove_participant(self, participant_uri, room_uri): try: room = self.get_room(room_uri) except RoomNotFoundError: pass else: log.info('Room %s - %s removed from conference' % (room_uri, participant_uri)) room.terminate_sessions(participant_uri) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidStart(self, notification): session = notification.sender room = self.get_room(session.request_uri, True) room.start() room.add_session(session) @run_in_green_thread def _NH_SIPSessionDidEnd(self, notification): session = notification.sender notification.center.remove_observer(self, sender=session) if session.direction == 'incoming': room_uri = session.request_uri else: # Clear invited participants mapping room_uri_str = '%s@%s' % (session.local_identity.uri.user, session.local_identity.uri.host) d = self.invited_participants_map[room_uri_str] d[str(session.remote_identity.uri)] -= 1 if d[str(session.remote_identity.uri)] == 0: del d[str(session.remote_identity.uri)] room_uri = session.local_identity.uri # We could get this notifiction even if we didn't get SIPSessionDidStart try: room = self.get_room(room_uri) except RoomNotFoundError: return if session in room.sessions: room.remove_session(session) if not room.stopping and room.empty: self.remove_room(room_uri) room.stop() def _NH_SIPSessionDidFail(self, notification): session = notification.sender notification.center.remove_observer(self, sender=session) - log.info(u'Session from %s failed: %s' % (session.remote_identity.uri, notification.data.reason)) + log.info('Session from %s failed: %s' % (session.remote_identity.uri, notification.data.reason)) +@implementer(IObserver) class IncomingReferralHandler(object): - implements(IObserver) def __init__(self, refer_request, data): self._refer_request = refer_request self._refer_headers = data.headers self.room_uri = data.request_uri self.room_uri_str = '%s@%s' % (self.room_uri.user, self.room_uri.host) self.refer_to_uri = re.sub('<|>', '', data.headers.get('Refer-To').uri) self.method = data.headers.get('Refer-To').parameters.get('method', 'INVITE').upper() self.session = None self.streams = [] def start(self): if not self.refer_to_uri.startswith(('sip:', 'sips:')): self.refer_to_uri = 'sip:%s' % self.refer_to_uri try: self.refer_to_uri = SIPURI.parse(self.refer_to_uri) except SIPCoreError: log.info('Room %s - failed to add %s' % (self.room_uri_str, self.refer_to_uri)) self._refer_request.reject(488) return notification_center = NotificationCenter() notification_center.add_observer(self, sender=self._refer_request) if self.method == 'INVITE': self._refer_request.accept() settings = SIPSimpleSettings() account = DefaultAccount() if account.sip.outbound_proxy is not None: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) else: uri = self.refer_to_uri lookup = DNSLookup() notification_center.add_observer(self, sender=lookup) lookup.lookup_sip_proxy(uri, settings.sip.transport_list) elif self.method == 'BYE': log.info('Room %s - %s removed %s from the room' % (self.room_uri_str, self._refer_headers.get('From').uri, self.refer_to_uri)) self._refer_request.accept() conference_application = ConferenceApplication() conference_application.remove_participant(self.refer_to_uri, self.room_uri) self._refer_request.end(200) else: self._refer_request.reject(488) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_DNSLookupDidSucceed(self, notification): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=notification.sender) account = DefaultAccount() conference_application = ConferenceApplication() try: room = conference_application.get_room(self.room_uri) except RoomNotFoundError: log.info('Room %s - failed to add %s' % (self.room_uri_str, self.refer_to_uri)) self._refer_request.end(500) return active_media = set(room.active_media).intersection(('audio', 'chat')) if not active_media: log.info('Room %s - failed to add %s' % (self.room_uri_str, self.refer_to_uri)) self._refer_request.end(500) return for stream_type in active_media: self.streams.append(MediaStreamRegistry.get(stream_type)()) self.session = Session(account) notification_center.add_observer(self, sender=self.session) original_from_header = self._refer_headers.get('From') if original_from_header.display_name: original_identity = "%s <%s@%s>" % (original_from_header.display_name, original_from_header.uri.user, original_from_header.uri.host) else: original_identity = "%s@%s" % (original_from_header.uri.user, original_from_header.uri.host) - from_header = FromHeader(SIPURI.new(self.room_uri), u'Conference Call') + from_header = FromHeader(SIPURI.new(self.room_uri), 'Conference Call') to_header = ToHeader(self.refer_to_uri) extra_headers = [] + if ThorNodeConfig.enabled: + extra_headers.append(Header('Thor-Scope', 'conference-invitation')) + # TODO: if this header is longer than 15 characters somehow the SIP packet gets corrupted -adi + extra_headers.append(Header('X-Orig-From', str(original_from_header.uri))) + extra_headers.append(SubjectHeader('Join conference request from %s' % original_identity)) if self._refer_headers.get('Referred-By', None) is not None: extra_headers.append(Header.new(self._refer_headers.get('Referred-By'))) else: extra_headers.append(Header('Referred-By', str(original_from_header.uri))) - if ThorNodeConfig.enabled: - extra_headers.append(Header('Thor-Scope', 'conference-invitation')) - extra_headers.append(Header('X-Originator-From', str(original_from_header.uri))) - extra_headers.append(SubjectHeader(u'Join conference request from %s' % original_identity)) route = notification.data.result[0] self.session.connect(from_header, to_header, route=route, streams=self.streams, is_focus=True, extra_headers=extra_headers) def _NH_DNSLookupDidFail(self, notification): notification.center.remove_observer(self, sender=notification.sender) def _NH_SIPSessionGotRingIndication(self, notification): if self._refer_request is not None: self._refer_request.send_notify(180) def _NH_SIPSessionGotProvisionalResponse(self, notification): if self._refer_request is not None: self._refer_request.send_notify(notification.data.code, notification.data.reason) def _NH_SIPSessionDidStart(self, notification): notification.center.remove_observer(self, sender=notification.sender) if self._refer_request is not None: self._refer_request.end(200) conference_application = ConferenceApplication() conference_application.add_participant(self.session, self.room_uri) log.info('Room %s - %s added %s' % (self.room_uri_str, self._refer_headers.get('From').uri, self.refer_to_uri)) self.session = None self.streams = [] def _NH_SIPSessionDidFail(self, notification): log.info('Room %s - failed to add %s: %s' % (self.room_uri_str, self.refer_to_uri, notification.data.reason)) notification.center.remove_observer(self, sender=notification.sender) if self._refer_request is not None: self._refer_request.end(notification.data.code or 500, notification.data.reason or notification.data.code) self.session = None self.streams = [] def _NH_SIPSessionDidEnd(self, notification): # If any stream fails to start we won't get SIPSessionDidFail, we'll get here instead log.info('Room %s - failed to add %s' % (self.room_uri_str, self.refer_to_uri)) notification.center.remove_observer(self, sender=notification.sender) if self._refer_request is not None: self._refer_request.end(200) self.session = None self.streams = [] def _NH_SIPIncomingReferralDidEnd(self, notification): notification.center.remove_observer(self, sender=notification.sender) self._refer_request = None diff --git a/sylk/applications/conference/room.py b/sylk/applications/conference/room.py index 28a3513..cd30f04 100644 --- a/sylk/applications/conference/room.py +++ b/sylk/applications/conference/room.py @@ -1,1073 +1,1073 @@ import os import random import shutil import string import weakref from collections import Counter, deque from glob import glob from itertools import chain, count, cycle from application.notification import IObserver, NotificationCenter from application.python import Null from application.system import makedirs from eventlib import api, coros, proc from sipsimple.account.bonjour import BonjourPresenceState from sipsimple.application import SIPApplication from sipsimple.audio import AudioConference, WavePlayer, WavePlayerError from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import SIPCoreError, SIPCoreInvalidStateError, SIPURI from sipsimple.core import Header, FromHeader, ToHeader, SubjectHeader from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.payloads import conference from sipsimple.streams import MediaStreamRegistry from sipsimple.streams.msrp.chat import ChatIdentity, CPIMHeader, CPIMNamespace from sipsimple.streams.msrp.filetransfer import FileSelector from sipsimple.threading import run_in_thread, run_in_twisted_thread from sipsimple.threading.green import run_in_green_thread from sipsimple.util import ISOTimestamp from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.applications.conference.configuration import get_room_config, ConferenceConfig from sylk.applications.conference.logger import log from sylk.bonjour import BonjourService from sylk.configuration import ServerConfig, ThorNodeConfig from sylk.configuration.datatypes import URL from sylk.resources import Resources from sylk.session import Session, IllegalStateError from sylk.web import server as web_server def format_identity(identity): uri = identity.uri if identity.display_name: - return u'%s <%s@%s>' % (identity.display_name, uri.user, uri.host) + return '%s <%s@%s>' % (identity.display_name, uri.user, uri.host) else: - return u'%s@%s' % (uri.user, uri.host) + return '%s@%s' % (uri.user, uri.host) class ScreenImage(object): def __init__(self, room, sender): self.room = weakref.ref(room) self.room_uri = room.uri self.sender = sender self.filename = os.path.join(ConferenceConfig.screensharing_images_dir, room.uri, '%s@%s_%s.jpg' % (sender.uri.user, sender.uri.host, ''.join(random.sample(string.letters+string.digits, 10)))) self.url = URL(web_server.url + '/conference/' + room.uri + '/screensharing') self.url.query_items['image'] = os.path.basename(self.filename) self.state = None self.timer = None @property def active(self): return self.state == 'active' @property def idle(self): return self.state == 'idle' @run_in_thread('file-io') def save(self, image): makedirs(os.path.dirname(self.filename)) tmp_filename = self.filename + '.tmp' try: with open(tmp_filename, 'wb') as file: file.write(image) except EnvironmentError as e: log.info('Room %s - cannot write screen sharing image: %s: %s' % (self.room_uri, self.filename, e)) else: try: os.rename(tmp_filename, self.filename) except EnvironmentError: pass self.advertise() @run_in_twisted_thread def advertise(self): if self.state == 'active': self.timer.reset(10) else: if self.timer is not None and self.timer.active(): self.timer.cancel() self.state = 'active' self.timer = reactor.callLater(10, self.stop_advertising) room = self.room() or Null room.dispatch_conference_info() txt = 'Room %s - %s is sharing the screen at %s' % (self.room_uri, format_identity(self.sender), self.url) room.dispatch_server_message(txt) log.info(txt) @run_in_twisted_thread def stop_advertising(self): if self.state != 'idle': if self.timer is not None and self.timer.active(): self.timer.cancel() self.state = 'idle' self.timer = None room = self.room() or Null room.dispatch_conference_info() txt = '%s stopped sharing the screen' % format_identity(self.sender) room.dispatch_server_message(txt) log.info(txt) +@implementer(IObserver) class Room(object): """ Object representing a conference room, it will handle the message dispatching among all the participants. """ - implements(IObserver) def __init__(self, uri): self.config = get_room_config(uri) self.uri = uri self.identity = ChatIdentity(SIPURI.parse('sip:%s' % self.uri), display_name='Conference Room') self.files = [] self.screen_images = {} self.subject = '' self.sessions = [] self.subscriptions = [] self.state = 'stopped' self.incoming_message_queue = coros.queue() self.message_dispatcher = None self.audio_conference = None self.moh_player = None self.conference_info_payload = None self.conference_info_version = count(1) self.bonjour_services = Null self.session_nickname_map = {} self.last_nicknames_map = {} self.participants_counter = Counter() self.history = deque(maxlen=ConferenceConfig.history_size) @property def empty(self): return len(self.sessions) == 0 @property def started(self): return self.state == 'started' @property def stopping(self): return self.state in ('stopping', 'stopped') @property def active_media(self): return set(stream.type for stream in chain(*(session.streams for session in self.sessions if session.streams))) @property def conference_info(self): if self.conference_info_payload is None: settings = SIPSimpleSettings() conference_description = conference.ConferenceDescription(display_text='Ad-hoc conference', free_text='Hosted by %s' % settings.user_agent, subject=self.subject) conference_description.conf_uris = conference.ConfUris() conference_description.conf_uris.add(conference.ConfUrisEntry('sip:%s' % self.uri, purpose='participation')) if self.config.advertise_xmpp_support: conference_description.conf_uris.add(conference.ConfUrisEntry('xmpp:%s' % self.uri, purpose='participation')) # TODO: add grouptextchat service uri for number in self.config.pstn_access_numbers: conference_description.conf_uris.add(conference.ConfUrisEntry('tel:%s' % number, purpose='participation')) host_info = conference.HostInfo(web_page=conference.WebPage('http://sylkserver.com')) self.conference_info_payload = conference.Conference(self.identity.uri, conference_description=conference_description, host_info=host_info, users=conference.Users()) self.conference_info_payload.version = next(self.conference_info_version) user_count = len(self.participants_counter) self.conference_info_payload.conference_state = conference.ConferenceState(user_count=user_count, active=True) users = conference.Users() for session in (session for session in self.sessions if not (len(session.streams) == 1 and session.streams[0].type == 'file-transfer')): try: user = next(user for user in users if user.entity == str(session.remote_identity.uri)) except StopIteration: display_text = self.last_nicknames_map.get(str(session.remote_identity.uri), session.remote_identity.display_name) user = conference.User(str(session.remote_identity.uri), display_text=display_text) user_uri = '%s@%s' % (session.remote_identity.uri.user, session.remote_identity.uri.host) screen_image = self.screen_images.get(user_uri, None) if screen_image is not None and screen_image.active: user.screen_image_url = screen_image.url users.add(user) joining_info = conference.JoiningInfo(when=session.start_time) holdable_streams = [stream for stream in session.streams if stream.hold_supported] session_on_hold = holdable_streams and all(stream.on_hold_by_remote for stream in holdable_streams) hold_status = conference.EndpointStatus('on-hold' if session_on_hold else 'connected') display_text = self.session_nickname_map.get(session, session.remote_identity.display_name) endpoint = conference.Endpoint(str(session._invitation.remote_contact_header.uri), display_text=display_text, joining_info=joining_info, status=hold_status) for stream in session.streams: if stream.type == 'file-transfer': continue endpoint.add(conference.Media(id(stream), media_type=self.format_conference_stream_type(stream))) user.add(endpoint) self.conference_info_payload.users = users if self.files: files = conference.FileResources(conference.FileResource(os.path.basename(file.name), file.hash, file.size, file.sender, 'OK') for file in self.files) self.conference_info_payload.conference_description.resources = conference.Resources(files=files) return self.conference_info_payload.toxml() def start(self): if self.started: return if ServerConfig.enable_bonjour and self.identity.uri.user != 'conference': - room_user = self.identity.uri.user + room_user = self.identity.uri.user.decode() self.bonjour_services = BonjourService(service='sipuri', name='Conference Room %s' % room_user, uri_user=room_user) self.bonjour_services.start() self.message_dispatcher = proc.spawn(self._message_dispatcher) self.audio_conference = AudioConference() self.audio_conference.hold() self.moh_player = MoHPlayer(self.audio_conference) self.moh_player.start() self.state = 'started' def stop(self): if not self.started: return self.state = 'stopping' self.bonjour_services.stop() self.bonjour_services = None self.incoming_message_queue.send_exception(api.GreenletExit) self.incoming_message_queue = None self.message_dispatcher.kill(proc.ProcExit) self.message_dispatcher = None self.moh_player.stop() self.moh_player = None self.audio_conference = None notification_center = NotificationCenter() for subscription in self.subscriptions: notification_center.remove_observer(self, sender=subscription) subscription.end() self.subscriptions = [] self.cleanup_files() self.conference_info_payload = None self.state = 'stopped' @run_in_thread('file-io') def cleanup_files(self): path = os.path.join(ConferenceConfig.file_transfer_dir, self.uri) try: shutil.rmtree(path) except EnvironmentError: pass path = os.path.join(ConferenceConfig.screensharing_images_dir, self.uri) try: shutil.rmtree(path) except EnvironmentError: pass def _message_dispatcher(self): """Read from self.incoming_message_queue and dispatch the messages to other participants""" while True: session, message_type, data = self.incoming_message_queue.wait() if message_type == 'message': message = data.message if message.sender.uri != session.remote_identity.uri: continue if message.content.startswith('?OTR:'): continue if message.timestamp is None: message.timestamp = ISOTimestamp.utcnow() message.sender.display_name = self.last_nicknames_map.get(str(session.remote_identity.uri), message.sender.display_name) recipient = message.recipients[0] private = len(message.recipients) == 1 and '%s@%s' % (recipient.uri.user, recipient.uri.host) != self.uri if private: self.dispatch_private_message(session, message) else: self.history.append(message) self.dispatch_message(session, message) elif message_type == 'composing_indication': if data.sender.uri != session.remote_identity.uri: continue recipient = data.recipients[0] private = len(data.recipients) == 1 and '%s@%s' % (recipient.uri.user, recipient.uri.host) != self.uri if private: self.dispatch_private_iscomposing(session, data) else: self.dispatch_iscomposing(session, data) def dispatch_message(self, session, message): for s in (s for s in self.sessions if s is not session): try: chat_stream = next(stream for stream in s.streams if stream.type == 'chat') except StopIteration: continue chat_stream.send_message(message.content, message.content_type, sender=message.sender, recipients=[self.identity], timestamp=message.timestamp, additional_headers=message.additional_headers) def dispatch_private_message(self, session, message): # Private messages are delivered to all sessions matching the recipient but also to the sender, # for replication in clients recipient = message.recipients[0] for s in (s for s in self.sessions if s is not session and s.remote_identity.uri in (recipient.uri, session.remote_identity.uri)): try: chat_stream = next(stream for stream in s.streams if stream.type == 'chat') except StopIteration: continue chat_stream.send_message(message.content, message.content_type, sender=message.sender, recipients=[recipient], timestamp=message.timestamp, additional_headers=message.additional_headers) def dispatch_iscomposing(self, session, data): identity = ChatIdentity(session.remote_identity.uri, session.remote_identity.display_name) for s in (s for s in self.sessions if s is not session): try: chat_stream = next(stream for stream in s.streams if stream.type == 'chat') except StopIteration: continue chat_stream.send_composing_indication(data.state, data.refresh, sender=identity, recipients=[self.identity]) def dispatch_private_iscomposing(self, session, data): identity = ChatIdentity(session.remote_identity.uri, session.remote_identity.display_name) recipient_uri = data.recipients[0].uri for s in (s for s in self.sessions if s is not session and s.remote_identity.uri == recipient_uri): try: chat_stream = next(stream for stream in s.streams if stream.type == 'chat') except StopIteration: continue chat_stream.send_composing_indication(data.state, data.refresh, sender=identity) def dispatch_server_message(self, content, content_type='text/plain', exclude=None): ns = CPIMNamespace('urn:ag-projects:xml:ns:cpim', prefix='agp') message_type = CPIMHeader('Message-Type', ns, 'status') for session in (session for session in self.sessions if session is not exclude): try: chat_stream = next(stream for stream in session.streams if stream.type == 'chat') except StopIteration: continue chat_stream.send_message(content, content_type, sender=self.identity, recipients=[self.identity], additional_headers=[message_type]) def dispatch_conference_info(self): data = self.conference_info for subscription in (subscription for subscription in self.subscriptions if subscription.state == 'active'): try: subscription.push_content(conference.ConferenceDocument.content_type, data) except (SIPCoreError, SIPCoreInvalidStateError): pass def dispatch_file(self, file): sender_uri = file.sender.uri for uri in set(session.remote_identity.uri for session in self.sessions if str(session.remote_identity.uri) != str(sender_uri)): handler = FileTransferHandler(self) handler.init_outgoing(uri, file) def add_session(self, session): notification_center = NotificationCenter() notification_center.add_observer(self, sender=session) self.sessions.append(session) remote_uri = str(session.remote_identity.uri) self.participants_counter[remote_uri] += 1 try: chat_stream = next(stream for stream in session.streams if stream.type == 'chat') except StopIteration: pass else: notification_center.add_observer(self, sender=chat_stream) try: audio_stream = next(stream for stream in session.streams if stream.type == 'audio') except StopIteration: pass else: notification_center.add_observer(self, sender=audio_stream) - log.info(u'Room %s - audio stream %s/%sHz, end-points: %s:%d <-> %s:%d' % (self.uri, audio_stream.codec, audio_stream.sample_rate, + log.info('Room %s - audio stream %s/%sHz, end-points: %s:%d <-> %s:%d' % (self.uri, audio_stream.codec, audio_stream.sample_rate, audio_stream.local_rtp_address, audio_stream.local_rtp_port, audio_stream.remote_rtp_address, audio_stream.remote_rtp_port)) if audio_stream.encryption.type != 'ZRTP': # We don't listen for stream notifications early enough if audio_stream.encryption.active: - log.info(u'Room %s - %s audio stream enabled %s encryption' % (self.uri, + log.info('Room %s - %s audio stream enabled %s encryption' % (self.uri, format_identity(session.remote_identity), audio_stream.encryption.type)) else: - log.info(u'Room %s - %s audio stream did not enable encryption' % (self.uri, + log.info('Room %s - %s audio stream did not enable encryption' % (self.uri, format_identity(session.remote_identity))) try: transfer_stream = next(stream for stream in session.streams if stream.type == 'file-transfer') except StopIteration: pass else: transfer_handler = FileTransferHandler(self) transfer_handler.init_incoming(transfer_stream) if transfer_stream.direction == 'recvonly': filename = os.path.basename(os.path.splitext(transfer_stream.file_selector.name)[0]) - txt = u'Room %s - %s is uploading file %s (%s)' % (self.uri, format_identity(session.remote_identity), filename,self.format_file_size(transfer_stream.file_selector.size)) + txt = 'Room %s - %s is uploading file %s (%s)' % (self.uri, format_identity(session.remote_identity), filename,self.format_file_size(transfer_stream.file_selector.size)) else: filename = os.path.basename(transfer_stream.file_selector.name) - txt = u'Room %s - %s requested file %s' % (self.uri, format_identity(session.remote_identity), filename) + txt = 'Room %s - %s requested file %s' % (self.uri, format_identity(session.remote_identity), filename) log.info(txt) self.dispatch_server_message(txt) if len(session.streams) == 1: return welcome_handler = WelcomeHandler(self, initial=True, session=session, streams=session.streams) welcome_handler.run() self.dispatch_conference_info() if len(self.sessions) == 1: - log.info(u'Room %s - started by %s with %s' % (self.uri, format_identity(session.remote_identity), self.format_stream_types(session.streams))) + log.info('Room %s - started by %s with %s' % (self.uri, format_identity(session.remote_identity), self.format_stream_types(session.streams))) else: - log.info(u'Room %s - %s joined with %s' % (self.uri, format_identity(session.remote_identity), self.format_stream_types(session.streams))) + log.info('Room %s - %s joined with %s' % (self.uri, format_identity(session.remote_identity), self.format_stream_types(session.streams))) if str(session.remote_identity.uri) not in set(str(s.remote_identity.uri) for s in self.sessions if s is not session): self.dispatch_server_message('%s has joined the room %s' % (format_identity(session.remote_identity), self.format_stream_types(session.streams)), exclude=session) if ServerConfig.enable_bonjour: self._update_bonjour_presence() def remove_session(self, session): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=session) self.sessions.remove(session) self.session_nickname_map.pop(session, None) remote_uri = str(session.remote_identity.uri) self.participants_counter[remote_uri] -= 1 if self.participants_counter[remote_uri] == 0: del self.participants_counter[remote_uri] self.last_nicknames_map.pop(remote_uri, None) try: chat_stream = next(stream for stream in session.streams or [] if stream.type == 'chat') except StopIteration: pass else: notification_center.remove_observer(self, sender=chat_stream) try: audio_stream = next(stream for stream in session.streams or [] if stream.type == 'audio') except StopIteration: pass else: notification_center.remove_observer(self, sender=audio_stream) try: self.audio_conference.remove(audio_stream) except ValueError: # User may hangup before getting bridged into the conference pass if len(self.audio_conference.streams) == 0: self.moh_player.pause() self.audio_conference.hold() elif len(self.audio_conference.streams) == 1: self.moh_player.play() try: next(stream for stream in session.streams if stream.type == 'file-transfer') except StopIteration: pass else: if len(session.streams) == 1: return self.dispatch_conference_info() - log.info(u'Room %s - %s left conference after %s' % (self.uri, format_identity(session.remote_identity), self.format_session_duration(session))) + log.info('Room %s - %s left conference after %s' % (self.uri, format_identity(session.remote_identity), self.format_session_duration(session))) if not self.sessions: - log.info(u'Room %s - Last participant left conference' % self.uri) + log.info('Room %s - Last participant left conference' % self.uri) if str(session.remote_identity.uri) not in set(str(s.remote_identity.uri) for s in self.sessions if s is not session): self.dispatch_server_message('%s has left the room after %s' % (format_identity(session.remote_identity), self.format_session_duration(session))) if ServerConfig.enable_bonjour: self._update_bonjour_presence() def terminate_sessions(self, uri): if not self.started: return for session in (session for session in self.sessions if session.remote_identity.uri == uri): session.end() def handle_incoming_subscription(self, subscribe_request, data): log.info('Room %s - subscription from %s' % (self.uri, data.headers['From'].uri)) - if subscribe_request.event != 'conference': + if subscribe_request.event != b'conference': #log.info('Room %s - Subscription for event %s rejected: only conference event is supported' % (self.uri, subscribe_request.event)) subscribe_request.reject(489) return NotificationCenter().add_observer(self, sender=subscribe_request) self.subscriptions.append(subscribe_request) try: subscribe_request.accept(conference.ConferenceDocument.content_type, self.conference_info) except SIPCoreError as e: log.warning('Error accepting SIP subscription: %s' % e) subscribe_request.end() def _accept_proposal(self, session, streams): try: session.accept_proposal(streams) except IllegalStateError: pass session.proposal_timer = None def add_file(self, file): self.dispatch_server_message('%s has uploaded file %s (%s)' % (format_identity(file.sender), os.path.basename(file.name), self.format_file_size(file.size))) self.files.append(file) self.dispatch_conference_info() if ConferenceConfig.push_file_transfer: self.dispatch_file(file) def add_screen_image(self, sender, image): sender_uri = '%s@%s' % (sender.uri.user, sender.uri.host) screen_image = self.screen_images.setdefault(sender_uri, ScreenImage(self, sender)) screen_image.save(image) def _update_bonjour_presence(self): num = len(self.sessions) if num == 0: num_str = 'No' elif num == 1: num_str = 'One' elif num == 2: num_str = 'Two' else: num_str = str(num) - txt = u'%s participant%s' % (num_str, '' if num==1 else 's') + txt = '%s participant%s' % (num_str, '' if num==1 else 's') presence_state = BonjourPresenceState('available', txt) if self.bonjour_services is Null: # This is the room being published all the time from sylk.applications.conference import ConferenceApplication ConferenceApplication().bonjour_room_service.presence_state = presence_state else: self.bonjour_services.presence_state = presence_state @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_RTPStreamDidEnableEncryption(self, notification): stream = notification.sender session = stream.session - log.info(u'Room %s - %s %s stream enabled %s encryption' % (self.uri, + log.info('Room %s - %s %s stream enabled %s encryption' % (self.uri, format_identity(session.remote_identity), stream.type, stream.encryption.type)) def _NH_RTPStreamDidNotEnableEncryption(self, notification): stream = notification.sender session = stream.session - log.info(u'Room %s - %s %s stream did not enable encryption: %s' % (self.uri, + log.info('Room %s - %s %s stream did not enable encryption: %s' % (self.uri, format_identity(session.remote_identity), stream.type, notification.data.reason)) def _NH_RTPStreamZRTPReceivedSAS(self, notification): if not self.config.zrtp_auto_verify: return stream = notification.sender session = stream.session sas = notification.data.sas # Send ZRTP SAS over the chat stream, if available try: chat_stream = next(stream for stream in session.streams if stream.type=='chat') except StopIteration: return # Only send the message if there are no relays in between secure_chat = chat_stream.transport == 'tls' and all(len(path)==1 for path in (chat_stream.msrp.full_local_path, chat_stream.msrp.full_remote_path)) if secure_chat: txt = 'Received ZRTP Short Authentication String: %s' % sas # Don't set the remote identity, that way it will appear as a private message ns = CPIMNamespace('urn:ag-projects:xml:ns:cpim', prefix='agp') message_type = CPIMHeader('Message-Type', ns, 'status') chat_stream.send_message(txt, 'text/plain', sender=self.identity, additional_headers=[message_type]) def _NH_RTPStreamDidTimeout(self, notification): stream = notification.sender if stream.type != 'audio': return session = stream.session - log.info(u'Room %s - audio stream for session %s timed out' % (self.uri, format_identity(session.remote_identity))) + log.info('Room %s - audio stream for session %s timed out' % (self.uri, format_identity(session.remote_identity))) if session.streams == [stream]: session.end() def _NH_ChatStreamGotMessage(self, notification): stream = notification.sender data = notification.data session = notification.sender.session message = data.message content_type = message.content_type.lower() if content_type.startswith(('text/', 'image/')): stream.msrp_session.send_report(notification.data.chunk, 200, 'OK') self.incoming_message_queue.send((session, 'message', data)) elif content_type == 'application/blink-screensharing': stream.msrp_session.send_report(notification.data.chunk, 200, 'OK') self.add_screen_image(message.sender, message.content) elif content_type == 'application/blink-zrtp-sas': if not self.config.zrtp_auto_verify: stream.msrp_session.send_report(notification.data.chunk, 413, 'Unwanted message') return try: audio_stream = next(stream for stream in session.streams if stream.type=='audio' and stream.encryption.active and stream.encryption.type=='ZRTP') except StopIteration: stream.msrp_session.send_report(notification.data.chunk, 413, 'Unwanted message') return # Only trust it if there was a direct path and the transport is TLS secure_chat = stream.transport == 'tls' and all(len(path)==1 for path in (stream.msrp.full_local_path, stream.msrp.full_remote_path)) remote_sas = str(message.content) if remote_sas == audio_stream.encryption.zrtp.sas and secure_chat: audio_stream.encryption.zrtp.verified = True stream.msrp_session.send_report(notification.data.chunk, 200, 'OK') else: stream.msrp_session.send_report(notification.data.chunk, 413, 'Unwanted message') else: stream.msrp_session.send_report(notification.data.chunk, 413, 'Unwanted message') def _NH_ChatStreamGotComposingIndication(self, notification): stream = notification.sender stream.msrp_session.send_report(notification.data.chunk, 200, 'OK') data = notification.data session = notification.sender.session self.incoming_message_queue.send((session, 'composing_indication', data)) def _NH_ChatStreamGotNicknameRequest(self, notification): nickname = notification.data.nickname session = notification.sender.session chunk = notification.data.chunk if nickname: - if nickname in self.session_nickname_map.values() and (session not in self.session_nickname_map or self.session_nickname_map[session] != nickname): + if nickname in list(self.session_nickname_map.values()) and (session not in self.session_nickname_map or self.session_nickname_map[session] != nickname): notification.sender.reject_nickname(chunk, 425, 'Nickname reserved or already in use') return self.session_nickname_map[session] = nickname self.last_nicknames_map[str(session.remote_identity.uri)] = nickname else: self.session_nickname_map.pop(session, None) self.last_nicknames_map.pop(str(session.remote_identity.uri), None) notification.sender.accept_nickname(chunk) self.dispatch_conference_info() def _NH_SIPIncomingSubscriptionDidEnd(self, notification): subscription = notification.sender try: self.subscriptions.remove(subscription) except ValueError: pass else: notification.center.remove_observer(self, sender=subscription) def _NH_SIPSessionDidChangeHoldState(self, notification): session = notification.sender if notification.data.originator == 'remote': if notification.data.on_hold: - log.info(u'Room %s - %s has put the audio session on hold' % (self.uri, format_identity(session.remote_identity))) + log.info('Room %s - %s has put the audio session on hold' % (self.uri, format_identity(session.remote_identity))) else: - log.info(u'Room %s - %s has taken the audio session out of hold' % (self.uri, format_identity(session.remote_identity))) + log.info('Room %s - %s has taken the audio session out of hold' % (self.uri, format_identity(session.remote_identity))) self.dispatch_conference_info() def _NH_SIPSessionNewProposal(self, notification): if notification.data.originator == 'remote': session = notification.sender audio_streams = [stream for stream in notification.data.proposed_streams if stream.type=='audio'] chat_streams = [stream for stream in notification.data.proposed_streams if stream.type=='chat'] if not audio_streams and not chat_streams: session.reject_proposal() return streams = [streams[0] for streams in (audio_streams, chat_streams) if streams] timer = reactor.callLater(3, self._accept_proposal, session, streams) old_timer = getattr(session, 'proposal_timer', None) assert old_timer is None session.proposal_timer = timer def _NH_SIPSessionProposalRejected(self, notification): if notification.data.originator == 'remote': session = notification.sender timer = getattr(session, 'proposal_timer', None) if timer is not None: timer.cancel() session.proposal_timer = None def _NH_SIPSessionHadProposalFailure(self, notification): if notification.data.originator == 'remote': session = notification.sender timer = getattr(session, 'proposal_timer', None) assert timer is not None timer.cancel() session.proposal_timer = None def _NH_SIPSessionDidRenegotiateStreams(self, notification): session = notification.sender for stream in notification.data.added_streams: notification.center.add_observer(self, sender=stream) - txt = u'%s has added %s' % (format_identity(session.remote_identity), stream.type) - log.info(u'Room %s - %s' % (self.uri, txt)) + txt = '%s has added %s' % (format_identity(session.remote_identity), stream.type) + log.info('Room %s - %s' % (self.uri, txt)) self.dispatch_server_message(txt, exclude=session) if stream.type == 'audio': - log.info(u'Room %s - audio stream %s/%sHz, end-points: %s:%d <-> %s:%d' % (self.uri, stream.codec, stream.sample_rate, + log.info('Room %s - audio stream %s/%sHz, end-points: %s:%d <-> %s:%d' % (self.uri, stream.codec, stream.sample_rate, stream.local_rtp_address, stream.local_rtp_port, stream.remote_rtp_address, stream.remote_rtp_port)) if stream.encryption.type != 'ZRTP': # We don't listen for stream notifications early enough if stream.encryption.active: - log.info(u'Room %s - %s %s stream enabled %s encryption' % (self.uri, + log.info('Room %s - %s %s stream enabled %s encryption' % (self.uri, format_identity(session.remote_identity), stream.type, stream.encryption.type)) else: - log.info(u'Room %s - %s %s stream did not enable encryption' % (self.uri, + log.info('Room %s - %s %s stream did not enable encryption' % (self.uri, format_identity(session.remote_identity), stream.type)) if notification.data.added_streams: welcome_handler = WelcomeHandler(self, initial=False, session=session, streams=notification.data.added_streams) welcome_handler.run() for stream in notification.data.removed_streams: notification.center.remove_observer(self, sender=stream) - txt = u'%s has removed %s' % (format_identity(session.remote_identity), stream.type) - log.info(u'Room %s - %s' % (self.uri, txt)) + txt = '%s has removed %s' % (format_identity(session.remote_identity), stream.type) + log.info('Room %s - %s' % (self.uri, txt)) self.dispatch_server_message(txt, exclude=session) if stream.type == 'audio': try: self.audio_conference.remove(stream) except ValueError: # User may hangup before getting bridged into the conference pass if len(self.audio_conference.streams) == 0: self.moh_player.pause() self.audio_conference.hold() elif len(self.audio_conference.streams) == 1: self.moh_player.play() if not session.streams: - log.info(u'Room %s - %s has removed all streams, session will be terminated' % (self.uri, format_identity(session.remote_identity))) + log.info('Room %s - %s has removed all streams, session will be terminated' % (self.uri, format_identity(session.remote_identity))) session.end() self.dispatch_conference_info() def _NH_SIPSessionTransferNewIncoming(self, notification): - log.info(u'Room %s - Call transfer request rejected, REFER must be out of dialog (RFC4579 5.5)' % self.uri) + log.info('Room %s - Call transfer request rejected, REFER must be out of dialog (RFC4579 5.5)' % self.uri) notification.sender.reject_transfer(403) def _NH_SIPSessionWillEnd(self, notification): session = notification.sender timer = getattr(session, 'proposal_timer', None) if timer is not None and timer.active(): timer.cancel() session.proposal_timer = None @staticmethod def format_stream_types(streams): if not streams: return '' if len(streams) == 1: txt = 'with %s' % streams[0].type else: txt = 'with %s' % ','.join(stream.type for stream in streams[:-1]) txt += ' and %s' % streams[-1:][0].type return txt @staticmethod def format_conference_stream_type(stream): if stream.type == 'chat': return 'message' return stream.type @staticmethod def format_session_duration(session): if session.start_time: duration = session.end_time - session.start_time seconds = duration.seconds if duration.microseconds < 500000 else duration.seconds+1 minutes, seconds = seconds / 60, seconds % 60 hours, minutes = minutes / 60, minutes % 60 hours += duration.days*24 if not minutes and not hours: duration_text = '%d seconds' % seconds elif not hours: duration_text = '%02d:%02d' % (minutes, seconds) else: duration_text = '%02d:%02d:%02d' % (hours, minutes, seconds) else: duration_text = '0s' return duration_text @staticmethod def format_file_size(size): infinite = float('infinity') boundaries = [( 1024, '%d bytes', 1), ( 10*1024, '%.2f KB', 1024.0), ( 1024*1024, '%.1f KB', 1024.0), ( 10*1024*1024, '%.2f MB', 1024*1024.0), (1024*1024*1024, '%.1f MB', 1024*1024.0), (10*1024*1024*1024, '%.2f GB', 1024*1024*1024.0), ( infinite, '%.1f GB', 1024*1024*1024.0)] for boundary, format, divisor in boundaries: if size < boundary: return format % (size/divisor,) else: return "%d bytes" % size +@implementer(IObserver) class MoHPlayer(object): - implements(IObserver) def __init__(self, conference): self.conference = conference self.files = None self.paused = None self._player = None def start(self): files = glob('%s/*.wav' % Resources.get('sounds/moh')) if not files: - log.error(u'No files found, MoH is disabled') + log.error('No files found, MoH is disabled') return random.shuffle(files) self.files = cycle(files) self._player = WavePlayer(SIPApplication.voice_audio_mixer, '', pause_time=1, initial_delay=1, volume=20) self.paused = True self.conference.bridge.add(self._player) NotificationCenter().add_observer(self, sender=self._player) def stop(self): if self._player is None: return NotificationCenter().remove_observer(self, sender=self._player) self._player.stop() self.paused = True self.conference.bridge.remove(self._player) self.conference = None def play(self): if self._player is not None and self.paused: self.paused = False self._play_next_file() def pause(self): if self._player is not None and not self.paused: self.paused = True self._player.stop() def _play_next_file(self): self._player.filename = next(self.files) self._player.play() @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_WavePlayerDidFail(self, notification): if not self.paused: self._play_next_file() _NH_WavePlayerDidEnd = _NH_WavePlayerDidFail +@implementer(IObserver) class WelcomeHandler(object): - implements(IObserver) def __init__(self, room, initial, session, streams): self.room = room self.initial = initial self.session = session self.streams = streams self.procs = proc.RunningProcSet() def run(self): notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) for stream in self.streams: if stream.type == 'audio': self.procs.spawn(self.audio_welcome, stream) elif stream.type == 'chat': self.procs.spawn(self.chat_welcome, stream) @run_in_green_thread def finalize(): try: self.procs.waitall() finally: notification_center.remove_observer(self, sender=self.session) self.session = None self.streams = None self.room = None self.procs = None finalize() def play_file_in_player(self, player, file, delay): player.filename = file player.pause_time = delay try: player.play().wait() except WavePlayerError as e: - log.warning(u'Error playing file %s: %s' % (file, e)) + log.warning('Error playing file %s: %s' % (file, e)) def audio_welcome(self, stream): player = WavePlayer(stream.mixer, '', pause_time=1, initial_delay=1, volume=50) stream.bridge.add(player) try: if self.initial: file = Resources.get('sounds/co_welcome_conference.wav') self.play_file_in_player(player, file, 1) user_count = len({str(s.remote_identity.uri) for s in self.room.sessions if s.remote_identity.uri != self.session.remote_identity.uri and any(stream for stream in s.streams if stream.type == 'audio')}) if user_count == 0: file = Resources.get('sounds/co_only_one.wav') self.play_file_in_player(player, file, 0.5) elif user_count == 1: file = Resources.get('sounds/co_there_is_one.wav') self.play_file_in_player(player, file, 0.5) elif user_count < 100: file = Resources.get('sounds/co_there_are.wav') self.play_file_in_player(player, file, 0.2) if user_count <= 24: file = Resources.get('sounds/bi_%d.wav' % user_count) self.play_file_in_player(player, file, 0.1) else: file = Resources.get('sounds/bi_%d0.wav' % (user_count / 10)) self.play_file_in_player(player, file, 0.1) file = Resources.get('sounds/bi_%d.wav' % (user_count % 10)) self.play_file_in_player(player, file, 0.1) file = Resources.get('sounds/co_more_participants.wav') self.play_file_in_player(player, file, 0) file = Resources.get('sounds/connected_tone.wav') self.play_file_in_player(player, file, 0.1) except proc.ProcExit: # No need to remove the bridge from the stream, it's done automatically pass else: stream.bridge.remove(player) self.room.audio_conference.add(stream) self.room.audio_conference.unhold() if len(self.room.audio_conference.streams) == 1: self.room.moh_player.play() else: self.room.moh_player.pause() finally: player.stop() def chat_welcome(self, stream): user_count = len({str(s.remote_identity.uri) for s in self.room.sessions if s.remote_identity.uri != self.session.remote_identity.uri}) if user_count == 0: participant_message = 'You are the first participant' elif user_count == 1: participant_message = 'There is one more participant' else: participant_message = 'There are {} more participants'.format(user_count) message = 'Welcome! {} in the conference. Others can join by using:\n\n'.format(participant_message) if self.room.config.advertise_xmpp_support: message += 'SIP/XMPP: {}\n'.format(self.room.uri) else: message += 'SIP: {}\n'.format(self.room.uri) if self.room.config.pstn_access_numbers: message += 'Phones: {}\n'.format(' or '.join(', '.join(sorted(self.room.config.pstn_access_numbers)).rsplit(', ', 1))) if self.room.config.webrtc_gateway_url: message += 'WEB: {}\n'.format(str(self.room.config.webrtc_gateway_url).replace('$room', self.room.uri)) stream.send_message(message.rstrip(), 'text/plain', sender=self.room.identity, recipients=[self.room.identity]) for msg in self.room.history: stream.send_message(msg.content, msg.content_type, sender=msg.sender, recipients=[self.room.identity], timestamp=msg.timestamp) # Send ZRTP SAS over the chat stream, if applicable if self.room.config.zrtp_auto_verify: session = stream.session try: audio_stream = next(stream for stream in session.streams if stream.type=='audio') except StopIteration: pass else: if audio_stream.encryption.type == 'ZRTP' and audio_stream.encryption.active: # Only send the message if there are no relays in between secure_chat = stream.transport == 'tls' and all(len(path)==1 for path in (stream.msrp.full_local_path, stream.msrp.full_remote_path)) sas = audio_stream.encryption.zrtp.sas if sas is not None and secure_chat: message = 'Received ZRTP Short Authentication String: %s' % sas # Don't set the remote identity, that way it will appear as a private message ns = CPIMNamespace('urn:ag-projects:xml:ns:cpim', prefix='agp') message_type = CPIMHeader('Message-Type', ns, 'status') stream.send_message(message, 'text/plain', sender=self.room.identity, additional_headers=[message_type]) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionWillEnd(self, notification): self.procs.killall() class RoomFile(object): def __init__(self, name, hash, size, sender): self.name = name self.hash = hash self.size = size self.sender = sender @property def file_selector(self): return FileSelector.for_file(self.name, hash=self.hash) +@implementer(IObserver) class FileTransferHandler(object): - implements(IObserver) def __init__(self, room): self.room = weakref.ref(room) self.session = None self.stream = None self.handler = None self.direction = None def init_incoming(self, stream): self.direction = 'incoming' self.stream = stream self.session = stream.session self.handler = stream.handler notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.stream) notification_center.add_observer(self, sender=self.handler) @run_in_green_thread def init_outgoing(self, destination, file): self.direction = 'outgoing' room = self.room() if room is None: return settings = SIPSimpleSettings() account = DefaultAccount() if account.sip.outbound_proxy is not None: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) else: uri = SIPURI.new(destination) lookup = DNSLookup() try: route = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait()[0] except (DNSLookupError, IndexError): return self.session = Session(account) self.stream = MediaStreamRegistry.get('file-transfer')(file.file_selector, 'sendonly') self.handler = self.stream.handler notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.stream) notification_center.add_observer(self, sender=self.handler) - from_header = FromHeader(SIPURI.new(room.identity.uri), u'Conference File Transfer') + from_header = FromHeader(SIPURI.new(room.identity.uri), 'Conference File Transfer') to_header = ToHeader(SIPURI.new(destination)) extra_headers = [] if ThorNodeConfig.enabled: extra_headers.append(Header('Thor-Scope', 'conference-invitation')) extra_headers.append(Header('X-Originator-From', str(file.sender.uri))) - extra_headers.append(SubjectHeader(u'File uploaded by %s' % file.sender)) + extra_headers.append(SubjectHeader('File uploaded by %s' % file.sender)) self.session.connect(from_header, to_header, route=route, streams=[self.stream], is_focus=True, extra_headers=extra_headers) def _terminate(self, failure_reason=None): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=self.stream) notification_center.remove_observer(self, sender=self.handler) room = self.room() if room is not None: if failure_reason is None: if self.direction == 'incoming' and self.stream.direction == 'recvonly': sender = ChatIdentity(self.session.remote_identity.uri, self.session.remote_identity.display_name) file = RoomFile(self.stream.file_selector.name, self.stream.file_selector.hash, self.stream.file_selector.size, sender) room.add_file(file) else: room.dispatch_server_message('File transfer for %s failed: %s' % (os.path.basename(self.stream.file_selector.name), failure_reason)) self.session = None self.stream = None self.handler = None @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_MediaStreamDidNotInitialize(self, notification): self._terminate(failure_reason=notification.data.reason) def _NH_FileTransferHandlerDidEnd(self, notification): if self.direction == 'incoming': if self.stream.direction == 'sendonly': reactor.callLater(3, self.session.end) else: reactor.callLater(1, self.session.end) else: self.session.end() self._terminate(failure_reason=notification.data.reason) diff --git a/sylk/applications/conference/web.py b/sylk/applications/conference/web.py index d882a67..972e141 100644 --- a/sylk/applications/conference/web.py +++ b/sylk/applications/conference/web.py @@ -1,85 +1,83 @@ import os -import urllib +import urllib.request, urllib.parse, urllib.error from application.python.types import Singleton from twisted.web.resource import ErrorPage, NoResource from twisted.web.static import File from sylk.applications.conference.configuration import ConferenceConfig from sylk.web import Klein -class ConferenceWeb(object): - __metaclass__ = Singleton - +class ConferenceWeb(object, metaclass=Singleton): app = Klein() screensharing_template = """ SylkServer Screen Sharing
""" def __init__(self, conference): self._resource = self.app.resource() self.conference = conference @property def resource(self): return self._resource @app.route('/') def home(self, request): return NoResource('Nothing to see here, move along.') @app.route('/') def room(self, request, room_uri): return NoResource('Nothing to see here, move along.') @app.route('//screensharing') def scheensharing(self, request, room_uri): try: room = self.conference._rooms[room_uri] except KeyError: return NoResource('Room not found') request.setHeader('Content-Type', 'text/html; charset=utf-8') if 'image' not in request.args or not request.args.get('image', [''])[0].endswith('jpg'): return ErrorPage(400, 'Bad Request', '\"image\" not provided') images_path = os.path.join(ConferenceConfig.screensharing_images_dir, room.uri) - image_path = os.path.basename(urllib.unquote(request.args['image'][0])) + image_path = os.path.basename(urllib.parse.unquote(request.args['image'][0])) if not os.path.isfile(os.path.join(images_path, image_path)): return NoResource('Image not found') image = os.path.join('screensharing_img', image_path) width = 'width: 100%' if 'fit' in request.args else '' return self.screensharing_template % dict(image=image, width=width) @app.route('//screensharing_img/') def screensharing_image(self, request, room_uri, filename): try: room = self.conference._rooms[room_uri] except KeyError: return NoResource('Room not found') images_path = os.path.join(ConferenceConfig.screensharing_images_dir, room.uri) return File(os.path.join(images_path, os.path.basename(filename))) diff --git a/sylk/applications/echo/__init__.py b/sylk/applications/echo/__init__.py index 5f18a83..22924a3 100644 --- a/sylk/applications/echo/__init__.py +++ b/sylk/applications/echo/__init__.py @@ -1,194 +1,195 @@ from application.notification import IObserver, NotificationCenter from application.python import Null from sipsimple.account.bonjour import BonjourPresenceState from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.applications import SylkApplication, ApplicationLogger from sylk.bonjour import BonjourService from sylk.configuration import ServerConfig log = ApplicationLogger(__package__) def format_identity(identity): if identity.display_name: - return u'%s ' % (identity.display_name, identity.uri.user, identity.uri.host) + return '%s ' % (identity.display_name, identity.uri.user, identity.uri.host) else: - return u'sip:%s@%s' % (identity.uri.user, identity.uri.host) + return 'sip:%s@%s' % (identity.uri.user, identity.uri.host) class EchoApplication(SylkApplication): def __init__(self): self.bonjour_services = set() def start(self): if ServerConfig.enable_bonjour: application_map = dict((item.split(':')) for item in ServerConfig.application_map) - for uri, app in application_map.iteritems(): + for uri, app in application_map.items(): if app == 'echo': service = BonjourService(service='sipuri', name='Echo Test', uri_user=uri, is_focus=False) service.start() - service.presence_state = BonjourPresenceState('available', u'Call me to test your client') + service.presence_state = BonjourPresenceState('available', 'Call me to test your client') self.bonjour_services.add(service) def stop(self): for service in self.bonjour_services: service.stop() self.bonjour_services.clear() def incoming_session(self, session): - log.info(u'New incoming session %s from %s' % (session.call_id, format_identity(session.remote_identity))) + peer = '%s:%s' % (session.transport, session.peer_address) + log.info('Session %s from %s to %s' % (session.call_id, peer, format_identity(session.remote_identity))) audio_streams = [stream for stream in session.proposed_streams if stream.type=='audio'] chat_streams = [stream for stream in session.proposed_streams if stream.type=='chat'] if not audio_streams and not chat_streams: - log.info(u'Session %s rejected: invalid media, only RTP audio and MSRP chat are supported' % session.call_id) + log.info('Session %s rejected: invalid media, only RTP audio and MSRP chat are supported' % session.call_id) session.reject(488) return if audio_streams: session.send_ring_indication() handler = EchoHandler(session, audio_streams[0] if audio_streams else None, chat_streams[0] if chat_streams else None) handler.run() def incoming_subscription(self, request, data): request.reject(405) def incoming_referral(self, request, data): request.reject(405) def incoming_message(self, request, data): request.reject(405) +@implementer(IObserver) class EchoHandler(object): - implements(IObserver) def __init__(self, session, audio_stream, chat_stream): self.session = session self.audio_stream = audio_stream self.chat_stream = chat_stream self.end_timer = None def run(self): notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) streams = [] if self.audio_stream is not None: streams.append(self.audio_stream) if self.chat_stream is not None: streams.append(self.chat_stream) reactor.callLater(2 if self.audio_stream is not None else 0, self._accept_session, self.session, streams) def _accept_session(self, session, streams): if session.state == 'incoming': session.accept(streams) def _make_audio_stream_echo(self, stream): if stream.producer_slot is not None and stream.consumer_slot is not None: # TODO: handle slot changes stream.bridge.remove(stream.device) stream.mixer.connect_slots(stream.producer_slot, stream.consumer_slot) def _cleanup(self): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=self.session) self.session = None if self.audio_stream is not None: notification_center.discard_observer(self, sender=self.audio_stream) self.audio_stream = None if self.chat_stream is not None: notification_center.discard_observer(self, sender=self.chat_stream) self.chat_stream = None if self.end_timer is not None and self.end_timer.active(): self.end_timer.cancel() self.end_timer = None def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidStart(self, notification): session = notification.sender try: audio_stream = next(stream for stream in session.streams if stream.type == 'audio') except StopIteration: audio_stream = None try: chat_stream = next(stream for stream in session.streams if stream.type == 'chat') except StopIteration: chat_stream = None log.info('Session %s started' % session.call_id) if audio_stream is not None: self._make_audio_stream_echo(audio_stream) notification.center.add_observer(self, sender=audio_stream) self.audio_stream = audio_stream if chat_stream is not None: notification.center.add_observer(self, sender=chat_stream) self.chat_stream = chat_stream self.end_timer = reactor.callLater(600, self.session.end) def _NH_SIPSessionDidEnd(self, notification): session = notification.sender log.info('Session %s ended' % session.call_id) self._cleanup() def _NH_SIPSessionDidFail(self, notification): session = notification.sender - log.info(u'Session %s failed from %s' % (session.call_id, format_identity(session.remote_identity))) + log.info('Session %s failed from %s' % (session.call_id, format_identity(session.remote_identity))) self._cleanup() def _NH_SIPSessionNewProposal(self, notification): if notification.data.originator == 'remote': session = notification.sender audio_streams = [stream for stream in notification.data.proposed_streams if stream.type=='audio'] chat_streams = [stream for stream in notification.data.proposed_streams if stream.type=='chat'] if not audio_streams and not chat_streams: session.reject_proposal() return streams = [streams[0] for streams in (audio_streams, chat_streams) if streams] session.accept_proposal(streams) def _NH_SIPSessionDidRenegotiateStreams(self, notification): session = notification.sender for stream in notification.data.added_streams: notification.center.add_observer(self, sender=stream) - log.info(u'Session %s has added %s' % (session.call_id, stream.type)) + log.info('Session %s has added %s' % (session.call_id, stream.type)) if stream.type == 'audio': self._make_audio_stream_echo(stream) self.audio_stream = stream elif stream.type == 'chat': self.chat_stream = stream for stream in notification.data.removed_streams: notification.center.remove_observer(self, sender=stream) - log.info(u'Session %s has removed %s' % (session.call_id, stream.type)) + log.info('Session %s has removed %s' % (session.call_id, stream.type)) if stream.type == 'audio': self.audio_stream = None elif stream.type == 'chat': self.chat_stream = None if not session.streams: - log.info(u'Session %s has removed all streams, session will be terminated' % session.call_id) + log.info('Session %s has removed all streams, session will be terminated' % session.call_id) session.end() def _NH_SIPSessionTransferNewIncoming(self, notification): notification.sender.reject_transfer(403) def _NH_AudioStreamGotDTMF(self, notification): - log.info(u'Session %s received DTMF: %s' % (self.session.call_id, notification.data.digit)) + log.info('Session %s received DTMF: %s' % (self.session.call_id, notification.data.digit)) def _NH_ChatStreamGotMessage(self, notification): stream = notification.sender message = notification.data.message stream.msrp_session.send_report(notification.data.chunk, 200, 'OK') stream.send_message(message.content, message.content_type) diff --git a/sylk/applications/ircconference/__init__.py b/sylk/applications/ircconference/__init__.py index 5a64a0f..137897b 100644 --- a/sylk/applications/ircconference/__init__.py +++ b/sylk/applications/ircconference/__init__.py @@ -1,98 +1,98 @@ from application.notification import IObserver, NotificationCenter from application.python import Null from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.applications import SylkApplication from sylk.applications.ircconference.logger import log from sylk.applications.ircconference.room import IRCRoom +@implementer(IObserver) class IRCConferenceApplication(SylkApplication): - implements(IObserver) def __init__(self): self.rooms = set() self.pending_sessions = [] def start(self): pass def stop(self): pass def incoming_session(self, session): log.info('New incoming session from %s' % session.remote_identity.uri) audio_streams = [stream for stream in session.proposed_streams if stream.type=='audio'] chat_streams = [stream for stream in session.proposed_streams if stream.type=='chat'] if not audio_streams and not chat_streams: session.reject(488) return self.pending_sessions.append(session) NotificationCenter().add_observer(self, sender=session) if audio_streams: session.send_ring_indication() if chat_streams: for stream in chat_streams: # Disable chatroom capabilities other than nickname stream.chatroom_capabilities = ['nickname'] streams = [streams[0] for streams in (audio_streams, chat_streams) if streams] reactor.callLater(4 if audio_streams else 0, self.accept_session, session, streams) def incoming_subscription(self, subscribe_request, data): to_header = data.headers.get('To', Null) if to_header is Null: subscribe_request.reject(400) return room = IRCRoom.get_room(data.request_uri) if not room.started: room = IRCRoom.get_room(to_header.uri) if not room.started: subscribe_request.reject(480) return room.handle_incoming_subscription(subscribe_request, data) def incoming_referral(self, request, data): request.reject(405) def incoming_message(self, request, data): request.reject(405) def accept_session(self, session, streams): if session in self.pending_sessions: session.accept(streams, is_focus=True) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidStart(self, notification): session = notification.sender self.pending_sessions.remove(session) room = IRCRoom.get_room(session.request_uri) room.start() room.add_session(session) self.rooms.add(room) def _NH_SIPSessionDidEnd(self, notification): session = notification.sender log.info('Session from %s ended' % session.remote_identity.uri) NotificationCenter().remove_observer(self, sender=session) room = IRCRoom.get_room(session.request_uri) if session in room.sessions: # We could get this notifiction even if we didn't get SIPSessionDidStart room.remove_session(session) if room.empty: room.stop() try: self.rooms.remove(room) except KeyError: pass def _NH_SIPSessionDidFail(self, notification): session = notification.sender self.pending_sessions.remove(session) log.info('Session from %s failed' % session.remote_identity.uri) diff --git a/sylk/applications/ircconference/room.py b/sylk/applications/ircconference/room.py index 3aaa7b1..d654d09 100644 --- a/sylk/applications/ircconference/room.py +++ b/sylk/applications/ircconference/room.py @@ -1,672 +1,671 @@ import random -import urllib +import urllib.request, urllib.parse, urllib.error import lxml.html import lxml.html.clean from itertools import count from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.types import Singleton from eventlib import coros, proc from sipsimple.audio import AudioConference, WavePlayer, WavePlayerError from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import SIPURI, SIPCoreError, SIPCoreInvalidStateError from sipsimple.payloads.conference import Conference, ConferenceDocument, ConferenceDescription, ConferenceState, Endpoint, EndpointStatus, HostInfo, JoiningInfo, Media, User, Users, WebPage from sipsimple.streams.msrp.chat import ChatIdentity from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import run_in_green_thread from twisted.internet import protocol, reactor from twisted.words.protocols import irc -from zope.interface import implements +from zope.interface import implementer from sylk.applications.ircconference.configuration import get_room_configuration from sylk.applications.ircconference.logger import log from sylk.resources import Resources def format_identity(identity): uri = identity.uri if identity.display_name: - return u'%s ' % (identity.display_name, uri.user, uri.host) + return '%s ' % (identity.display_name, uri.user, uri.host) else: - return u'sip:%s@%s' % (uri.user, uri.host) + return 'sip:%s@%s' % (uri.user, uri.host) def format_stream_types(streams): if not streams: return '' if len(streams) == 1: txt = 'with %s' % streams[0].type else: txt = 'with %s' % ','.join(stream.type for stream in streams[:-1]) txt += ' and %s' % streams[-1:][0].type return txt def format_session_duration(session): if session.start_time: duration = session.end_time - session.start_time seconds = duration.seconds if duration.microseconds < 500000 else duration.seconds+1 minutes, seconds = seconds / 60, seconds % 60 hours, minutes = minutes / 60, minutes % 60 hours += duration.days*24 if not minutes and not hours: duration_text = '%d seconds' % seconds elif not hours: duration_text = '%02d:%02d' % (minutes, seconds) else: duration_text = '%02d:%02d:%02d' % (hours, minutes, seconds) else: duration_text = '0s' return duration_text def format_conference_stream_type(stream): if stream.type == 'chat': return 'message' return stream.type def html2text(data): try: doc = lxml.html.document_fromstring(data) cleaner = lxml.html.clean.Cleaner(style=True) doc = cleaner.clean_html(doc) return doc.text_content().strip('\n') except Exception: return '' class IRCMessage(object): def __init__(self, username, uri, content, content_type='text/plain'): self.sender = ChatIdentity(uri, display_name=username) self.content = content self.content_type = content_type -class IRCRoom(object): +@implementer(IObserver) +class IRCRoom(object, metaclass=Singleton): """ Object representing a conference room, it will handle the message dispatching among all the participants. """ - __metaclass__ = Singleton - implements(IObserver) def __init__(self, uri): self.uri = uri self.identity = ChatIdentity.parse('' % self.uri) self.sessions = [] self.sessions_with_proposals = [] self.subscriptions = [] self.pending_messages = [] self.state = 'stopped' self.incoming_message_queue = coros.queue() self.message_dispatcher = None self.audio_conference = None self.conference_info_payload = None self.conference_info_version = count(1) self.irc_connector = None self.irc_protocol = None @classmethod def get_room(cls, uri): room_uri = '%s@%s' % (uri.user, uri.host) room = cls(room_uri) return room @property def empty(self): return len(self.sessions) == 0 @property def started(self): return self.state == 'started' def start(self): if self.state != 'stopped': return config = get_room_configuration(self.uri.split('@')[0]) factory = IRCBotFactory(config) host, port = config.server self.irc_connector = reactor.connectTCP(host, port, factory) NotificationCenter().add_observer(self, sender=self.irc_connector.factory) self.message_dispatcher = proc.spawn(self._message_dispatcher) self.audio_conference = AudioConference() self.audio_conference.hold() self.state = 'started' def stop(self): if self.state != 'started': return self.state = 'stopped' NotificationCenter().remove_observer(self, sender=self.irc_connector.factory) self.irc_connector.factory.stop_requested = True self.irc_connector.disconnect() self.irc_connector = None self.message_dispatcher.kill(proc.ProcExit) self.audio_conference = None def _message_dispatcher(self): """Read from self.incoming_message_queue and dispatch the messages to other participants""" while True: session, message_type, data = self.incoming_message_queue.wait() if message_type == 'msrp_message': if data.sender.uri != session.remote_identity.uri: return self.dispatch_message(session, data) elif message_type == 'irc_message': self.dispatch_irc_message(data) def dispatch_message(self, session, message): identity = ChatIdentity.parse(format_identity(session.remote_identity)) for s in (s for s in self.sessions if s is not session): try: chat_stream = next(stream for stream in s.streams if stream.type == 'chat') except StopIteration: pass else: chat_stream.send_message(message.content, message.content_type, sender=identity, recipients=[self.identity], timestamp=message.timestamp) def dispatch_irc_message(self, message): for session in self.sessions: try: chat_stream = next(stream for stream in session.streams if stream.type == 'chat') except StopIteration: pass else: chat_stream.send_message(message.content, message.content_type, sender=message.sender, recipients=[self.identity]) def dispatch_server_message(self, content, content_type='text/plain', exclude=None): for session in (session for session in self.sessions if session is not exclude): try: chat_stream = next(stream for stream in session.streams if stream.type == 'chat') except StopIteration: pass else: chat_stream.send_message(content, content_type, sender=self.identity, recipients=[self.identity]) def get_conference_info(self): # Send request to get participants list, we'll get a notification with it if self.irc_protocol is not None: self.irc_protocol.get_participants() else: self.dispatch_conference_info([]) def dispatch_conference_info(self, irc_participants): data = self.build_conference_info_payload(irc_participants) for subscription in (subscription for subscription in self.subscriptions if subscription.state == 'active'): try: subscription.push_content(ConferenceDocument.content_type, data) except (SIPCoreError, SIPCoreInvalidStateError): pass def build_conference_info_payload(self, irc_participants): irc_configuration = get_room_configuration(self.uri.split('@')[0]) if self.conference_info_payload is None: settings = SIPSimpleSettings() conference_description = ConferenceDescription(display_text='#%s on %s' % (irc_configuration.channel, irc_configuration.server[0]), free_text='Hosted by %s' % settings.user_agent) host_info = HostInfo(web_page=WebPage(irc_configuration.website)) self.conference_info_payload = Conference(self.identity.uri, conference_description=conference_description, host_info=host_info, users=Users()) self.conference_info_payload.version = next(self.conference_info_version) user_count = len({str(s.remote_identity.uri) for s in self.sessions}) + len(irc_participants) self.conference_info_payload.conference_state = ConferenceState(user_count=user_count, active=True) users = Users() for session in self.sessions: try: user = next(user for user in users if user.entity == str(session.remote_identity.uri)) except StopIteration: user = User(str(session.remote_identity.uri), display_text=session.remote_identity.display_name) users.add(user) joining_info = JoiningInfo(when=session.start_time) holdable_streams = [stream for stream in session.streams if stream.hold_supported] session_on_hold = holdable_streams and all(stream.on_hold_by_remote for stream in holdable_streams) hold_status = EndpointStatus('on-hold' if session_on_hold else 'connected') endpoint = Endpoint(str(session._invitation.remote_contact_header.uri), display_text=session.remote_identity.display_name, joining_info=joining_info, status=hold_status) for stream in session.streams: endpoint.add(Media(id(stream), media_type=format_conference_stream_type(stream))) user.add(endpoint) for nick in irc_participants: - irc_uri = '%s@%s' % (urllib.quote(nick), irc_configuration.server[0]) + irc_uri = '%s@%s' % (urllib.parse.quote(nick), irc_configuration.server[0]) user = User(irc_uri, display_text=nick) users.add(user) endpoint = Endpoint(irc_uri, display_text=nick) endpoint.add(Media(random.randint(100000000, 999999999), media_type='message')) user.add(endpoint) self.conference_info_payload.users = users return self.conference_info_payload.toxml() def add_session(self, session): notification_center = NotificationCenter() notification_center.add_observer(self, sender=session) self.sessions.append(session) try: chat_stream = next(stream for stream in session.streams if stream.type == 'chat') except StopIteration: pass else: notification_center.add_observer(self, sender=chat_stream) try: audio_stream = next(stream for stream in session.streams if stream.type == 'audio') except StopIteration: pass else: notification_center.add_observer(self, sender=audio_stream) - log.info(u'Audio stream using %s/%sHz, end-points: %s:%d <-> %s:%d' % (audio_stream.codec, audio_stream.sample_rate, + log.info('Audio stream using %s/%sHz, end-points: %s:%d <-> %s:%d' % (audio_stream.codec, audio_stream.sample_rate, audio_stream.local_rtp_address, audio_stream.local_rtp_port, audio_stream.remote_rtp_address, audio_stream.remote_rtp_port)) welcome_handler = WelcomeHandler(self, session) welcome_handler.start() self.get_conference_info() if len(self.sessions) == 1: - log.info(u'%s started conference %s %s' % (format_identity(session.remote_identity), self.uri, format_stream_types(session.streams))) + log.info('%s started conference %s %s' % (format_identity(session.remote_identity), self.uri, format_stream_types(session.streams))) else: - log.info(u'%s joined conference %s %s' % (format_identity(session.remote_identity), self.uri, format_stream_types(session.streams))) + log.info('%s joined conference %s %s' % (format_identity(session.remote_identity), self.uri, format_stream_types(session.streams))) if str(session.remote_identity.uri) not in set(str(s.remote_identity.uri) for s in self.sessions if s is not session): self.dispatch_server_message('%s has joined the room %s' % (format_identity(session.remote_identity), format_stream_types(session.streams)), exclude=session) def remove_session(self, session): notification_center = NotificationCenter() try: chat_stream = next(stream for stream in session.streams or [] if stream.type == 'chat') except StopIteration: pass else: notification_center.remove_observer(self, sender=chat_stream) try: audio_stream = next(stream for stream in session.streams or [] if stream.type == 'audio') except StopIteration: pass else: notification_center.remove_observer(self, sender=audio_stream) try: self.audio_conference.remove(audio_stream) except ValueError: # User may hangup before getting bridged into the conference pass if len(self.audio_conference.streams) == 0: self.audio_conference.hold() notification_center.remove_observer(self, sender=session) self.sessions.remove(session) self.get_conference_info() - log.info(u'%s left conference %s after %s' % (format_identity(session.remote_identity), self.uri, format_session_duration(session))) + log.info('%s left conference %s after %s' % (format_identity(session.remote_identity), self.uri, format_session_duration(session))) if not self.sessions: - log.info(u'Last participant left conference %s' % self.uri) + log.info('Last participant left conference %s' % self.uri) if str(session.remote_identity.uri) not in set(str(s.remote_identity.uri) for s in self.sessions if s is not session): self.dispatch_server_message('%s has left the room after %s' % (format_identity(session.remote_identity), format_session_duration(session))) def accept_proposal(self, session, streams): if session in self.sessions_with_proposals: session.accept_proposal(streams) self.sessions_with_proposals.remove(session) def handle_incoming_subscription(self, subscribe_request, data): if subscribe_request.event != 'conference': subscribe_request.reject(489) return NotificationCenter().add_observer(self, sender=subscribe_request) self.subscriptions.append(subscribe_request) try: subscribe_request.accept() except SIPCoreError as e: log.warning('Error accepting SIP subscription: %s' % e) subscribe_request.end() self.get_conference_info() @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPIncomingSubscriptionDidEnd(self, notification): subscription = notification.sender notification_center = NotificationCenter() notification_center.remove_observer(self, sender=subscription) self.subscriptions.remove(subscription) def _NH_SIPSessionDidChangeHoldState(self, notification): session = notification.sender if notification.data.originator == 'remote': if notification.data.on_hold: - log.info(u'%s has put the audio session on hold' % format_identity(session.remote_identity)) + log.info('%s has put the audio session on hold' % format_identity(session.remote_identity)) else: - log.info(u'%s has taken the audio session out of hold' % format_identity(session.remote_identity)) + log.info('%s has taken the audio session out of hold' % format_identity(session.remote_identity)) self.get_conference_info() def _NH_SIPSessionNewProposal(self, notification): if notification.data.originator == 'remote': session = notification.sender audio_streams = [stream for stream in notification.data.proposed_streams if stream.type=='audio'] chat_streams = [stream for stream in notification.data.proposed_streams if stream.type=='chat'] if not audio_streams and not chat_streams: session.reject_proposal() return if chat_streams: chat_streams[0].chatroom_capabilities = [] streams = [streams[0] for streams in (audio_streams, chat_streams) if streams] self.sessions_with_proposals.append(session) reactor.callLater(4, self.accept_proposal, session, streams) def _NH_SIPSessionProposalRejected(self, notification): session = notification.sender self.sessions_with_proposals.remove(session) def _NH_SIPSessionDidRenegotiateStreams(self, notification): session = notification.sender for stream in notification.data.added_streams: notification.center.add_observer(self, sender=stream) - log.info(u'%s has added %s to %s' % (format_identity(session.remote_identity), stream.type, self.uri)) + log.info('%s has added %s to %s' % (format_identity(session.remote_identity), stream.type, self.uri)) self.dispatch_server_message('%s has added %s' % (format_identity(session.remote_identity), stream.type), exclude=session) if stream.type == 'audio': - log.info(u'Audio stream using %s/%sHz, end-points: %s:%d <-> %s:%d' % (stream.codec, stream.sample_rate, + log.info('Audio stream using %s/%sHz, end-points: %s:%d <-> %s:%d' % (stream.codec, stream.sample_rate, stream.local_rtp_address, stream.local_rtp_port, stream.remote_rtp_address, stream.remote_rtp_port)) welcome_handler = WelcomeHandler(self, session) welcome_handler.start(welcome_prompt=False) for stream in notification.data.removed_streams: notification.center.remove_observer(self, sender=stream) - log.info(u'%s has removed %s from %s' % (format_identity(session.remote_identity), stream.type, self.uri)) + log.info('%s has removed %s from %s' % (format_identity(session.remote_identity), stream.type, self.uri)) self.dispatch_server_message('%s has removed %s' % (format_identity(session.remote_identity), stream.type), exclude=session) if stream.type == 'audio': try: self.audio_conference.remove(stream) except ValueError: # User may hangup before getting bridged into the conference pass if len(self.audio_conference.streams) == 0: self.audio_conference.hold() if not session.streams: - log.info(u'%s has removed all streams from %s, session will be terminated' % (format_identity(session.remote_identity), self.uri)) + log.info('%s has removed all streams from %s, session will be terminated' % (format_identity(session.remote_identity), self.uri)) session.end() self.get_conference_info() def _NH_RTPStreamDidTimeout(self, notification): stream = notification.sender if stream.type != 'audio': return session = stream.session - log.info(u'Audio stream for session %s timed out' % format_identity(session.remote_identity)) + log.info('Audio stream for session %s timed out' % format_identity(session.remote_identity)) if session.streams == [stream]: session.end() def _NH_ChatStreamGotMessage(self, notification): stream = notification.sender message = notification.data.message if message.content_type not in ('text/html', 'text/plain'): - log.info(u'Unsupported content type: %s, ignoring message' % message.content_type) + log.info('Unsupported content type: %s, ignoring message' % message.content_type) stream.msrp_session.send_report(notification.data.chunk, 413, 'Unwanted message') return stream.msrp_session.send_report(notification.data.chunk, 200, 'OK') # Send MSRP chat message to other participants session = stream.session self.incoming_message_queue.send((session, 'msrp_message', message)) # Send MSRP chat message to IRC chat room if message.content_type == 'text/html': content = html2text(message.content) elif message.content_type == 'text/plain': content = message.content else: log.warning('unexpected message type: %s' % message.content_type) return sender = message.sender irc_message = '%s: %s' % (format_identity(sender), content) if self.irc_protocol is not None: self.irc_protocol.send_message(irc_message.encode('utf-8')) else: self.pending_messages.append(irc_message) def _NH_ChatStreamGotNicknameRequest(self, notification): # Discard the nickname but pretend we accept it so that XMPP clients can work chunk = notification.data.chunk notification.sender.accept_nickname(chunk) def _NH_IRCBotGotConnected(self, notification): self.irc_protocol = notification.data.protocol # Send enqueued messages while self.pending_messages: message = self.pending_messages.pop(0) self.irc_protocol.send_message(message.encode('utf-8')) # Update participants list self.get_conference_info() def _NH_IRCBotGotDisconnected(self, notification): self.irc_protocol = None def _NH_IRCBotGotMessage(self, notification): message = notification.data.message self.incoming_message_queue.send((None, 'irc_message', message)) def _NH_IRCBotGotParticipantsList(self, notification): self.dispatch_conference_info(notification.data.participants) def _NH_IRCBotJoinedChannel(self, notification): self.get_conference_info() def _NH_IRCBotUserJoined(self, notification): self.dispatch_server_message('%s joined the IRC channel' % notification.data.user) self.get_conference_info() def _NH_IRCBotUserLeft(self, notification): self.dispatch_server_message('%s left the IRC channel' % notification.data.user) self.get_conference_info() def _NH_IRCBotUserQuit(self, notification): self.dispatch_server_message('%s quit the IRC channel: %s' % (notification.data.user, notification.data.reason)) self.get_conference_info() def _NH_IRCBotUserKicked(self, notification): data = notification.data self.dispatch_server_message('%s kicked %s out of the IRC channel: %s' % (data.kicker, data.kickee, data.reason)) self.get_conference_info() def _NH_IRCBotUserRenamed(self, notification): self.dispatch_server_message('%s changed his name to %s' % (notification.data.oldname, notification.data.newname)) self.get_conference_info() def _NH_IRCBotUserAction(self, notification): self.dispatch_server_message('%s %s' % (notification.data.user, notification.data.action)) +@implementer(IObserver) class WelcomeHandler(object): - implements(IObserver) def __init__(self, room, session): self.room = room self.session = session self.proc = None @run_in_green_thread def start(self, welcome_prompt=True): notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) self.proc = proc.spawn(self.play_audio_welcome, welcome_prompt) self.proc.wait() notification_center.remove_observer(self, sender=self.session) self.session = None self.room = None self.proc = None def play_file_in_player(self, player, file, delay): player.filename = file player.pause_time = delay try: player.play().wait() except WavePlayerError as e: - log.warning(u'Error playing file %s: %s' % (file, e)) + log.warning('Error playing file %s: %s' % (file, e)) def play_audio_welcome(self, welcome_prompt): try: audio_stream = next(stream for stream in self.session.streams if stream.type == 'audio') except StopIteration: return player = WavePlayer(audio_stream.mixer, '', pause_time=1, initial_delay=1, volume=50) audio_stream.bridge.add(player) try: if welcome_prompt: file = Resources.get('sounds/co_welcome_conference.wav') self.play_file_in_player(player, file, 1) user_count = len({str(s.remote_identity.uri) for s in self.room.sessions if s.remote_identity.uri != self.session.remote_identity.uri and any(stream for stream in s.streams if stream.type == 'audio')}) if user_count == 0: file = Resources.get('sounds/co_only_one.wav') self.play_file_in_player(player, file, 0.5) elif user_count == 1: file = Resources.get('sounds/co_there_is_one.wav') self.play_file_in_player(player, file, 0.5) elif user_count < 100: file = Resources.get('sounds/co_there_are.wav') self.play_file_in_player(player, file, 0.2) if user_count <= 24: file = Resources.get('sounds/bi_%d.wav' % user_count) self.play_file_in_player(player, file, 0.1) else: file = Resources.get('sounds/bi_%d0.wav' % (user_count / 10)) self.play_file_in_player(player, file, 0.1) file = Resources.get('sounds/bi_%d.wav' % (user_count % 10)) self.play_file_in_player(player, file, 0.1) file = Resources.get('sounds/co_more_participants.wav') self.play_file_in_player(player, file, 0) file = Resources.get('sounds/connected_tone.wav') self.play_file_in_player(player, file, 0.1) except proc.ProcExit: # No need to remove the bridge from the stream, it's done automatically pass else: audio_stream.bridge.remove(player) self.room.audio_conference.add(audio_stream) self.room.audio_conference.unhold() finally: player.stop() def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionWillEnd(self, notification): self.proc.kill() class IRCBot(irc.IRCClient): nickname = 'SylkServer' def __init__(self): self._nick_collector = [] self.nicks = [] def connectionMade(self): irc.IRCClient.connectionMade(self) log.info('Connection to IRC has been established') NotificationCenter().post_notification('IRCBotGotConnected', self.factory, NotificationData(protocol=self)) def connectionLost(self, failure): irc.IRCClient.connectionLost(self, failure) NotificationCenter().post_notification('IRCBotGotDisconnected', self.factory, NotificationData()) def signedOn(self): log.info('Logging into %s channel...' % self.factory.channel) self.join(self.factory.channel) def kickedFrom(self, channel, kicker, message): log.info('Got kicked from %s by %s: %s. Rejoining...' % (channel, kicker, message)) self.join(self.factory.channel) def joined(self, channel): log.info('Logged into %s channel' % channel) NotificationCenter().post_notification('IRCBotJoinedChannel', self.factory, NotificationData(channel=self.factory.channel)) def privmsg(self, user, channel, message): if channel == '*': return username = user.split('!', 1)[0] if username == self.nickname: return if channel == self.nickname: self.msg(username, "Sorry, I don't support private messages, I'm a bot.") return - uri = SIPURI.parse('sip:%s@%s' % (urllib.quote(username), self.factory.config.server[0])) + uri = SIPURI.parse('sip:%s@%s' % (urllib.parse.quote(username), self.factory.config.server[0])) irc_message = IRCMessage(username, uri, message.decode('utf-8')) data = NotificationData(message=irc_message) NotificationCenter().post_notification('IRCBotGotMessage', self.factory, data) def send_message(self, message): self.say(self.factory.channel, message) def get_participants(self): self.sendLine("NAMES #%s" % self.factory.channel) def got_participants(self, nicks): data = NotificationData(participants=nicks) NotificationCenter().post_notification('IRCBotGotParticipantsList', self.factory, data) def irc_RPL_NAMREPLY(self, prefix, params): """Collect usernames from this channel. Several of these messages may be sent to cover the channel's full nicklist. An RPL_ENDOFNAMES signals the end of the list. """ # We just separate these into individual nicks and stuff them in # the nickCollector, transferred to 'nicks' when we get the RPL_ENDOFNAMES. for name in params[3].split(): # Remove operator and voice prefixes if name[0] in '@+': name = name[1:] if name != self.nickname: self._nick_collector.append(name) def irc_RPL_ENDOFNAMES(self, prefix, params): """This is sent after zero or more RPL_NAMREPLY commands to terminate the list of users in a channel. """ self.nicks = self._nick_collector self._nick_collector = [] self.got_participants(self.nicks) def userJoined(self, user, channel): if channel.strip('#') == self.factory.channel: data = NotificationData(user=user) NotificationCenter().post_notification('IRCBotUserJoined', self.factory, data) def userLeft(self, user, channel): if channel.strip('#') == self.factory.channel: data = NotificationData(user=user) NotificationCenter().post_notification('IRCBotUserLeft', self.factory, data) def userQuit(self, user, reason): data = NotificationData(user=user, reason=reason) NotificationCenter().post_notification('IRCBotUserQuit', self.factory, data) def userKicked(self, kickee, channel, kicker, message): if channel.strip('#') == self.factory.channel: data = NotificationData(kickee=kickee, kicker=kicker, reason=message) NotificationCenter().post_notification('IRCBotUserKicked', self.factory, data) def userRenamed(self, oldname, newname): data = NotificationData(oldname=oldname, newname=newname) NotificationCenter().post_notification('IRCBotUserRenamed', self.factory, data) def action(self, user, channel, data): if channel.strip('#') == self.factory.channel: username = user.split('!', 1)[0] data = NotificationData(user=username, action=data) NotificationCenter().post_notification('IRCBotUserAction', self.factory, data) class IRCBotFactory(protocol.ClientFactory): protocol = IRCBot def __init__(self, config): self.config = config self.channel = config.channel self.stop_requested = False def clientConnectionLost(self, connector, failure): log.info('Disconnected from IRC: %s' % failure.getErrorMessage()) if not self.stop_requested: log.info('Reconnecting...') connector.connect() def clientConnectionFailed(self, connector, failure): log.error('Connection to IRC server failed: %s' % failure.getErrorMessage()) diff --git a/sylk/applications/playback/__init__.py b/sylk/applications/playback/__init__.py index 576d999..8329bbc 100644 --- a/sylk/applications/playback/__init__.py +++ b/sylk/applications/playback/__init__.py @@ -1,169 +1,169 @@ import os from application.python import Null from application.notification import IObserver, NotificationCenter from eventlib import proc from sipsimple.account.bonjour import BonjourPresenceState from sipsimple.audio import WavePlayer, WavePlayerError from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.applications import SylkApplication, ApplicationLogger from sylk.applications.playback.configuration import get_config from sylk.bonjour import BonjourService from sylk.configuration import ServerConfig log = ApplicationLogger(__package__) class PlaybackApplication(SylkApplication): def start(self): self.bonjour_services = [] if ServerConfig.enable_bonjour: application_map = dict((item.split(':')) for item in ServerConfig.application_map) - for uri, app in application_map.iteritems(): + for uri, app in application_map.items(): if app == 'playback': config = get_config('%s' % uri) if config is None: continue if os.path.isfile(config.file) and os.access(config.file, os.R_OK): service = BonjourService(service='sipuri', name='Playback Test', uri_user=uri, is_focus=False) service.start() - service.presence_state = BonjourPresenceState('available', u'File: %s' % os.path.basename(config.file)) + service.presence_state = BonjourPresenceState('available', 'File: %s' % os.path.basename(config.file)) self.bonjour_services.append(service) def stop(self): for service in self.bonjour_services: service.stop() del self.bonjour_services[:] def incoming_session(self, session): log.info('Session %s from %s to %s' % (session.call_id, session.remote_identity.uri, session.local_identity.uri)) config = get_config('%s@%s' % (session.request_uri.user, session.request_uri.host)) if config is None: config = get_config('%s' % session.request_uri.user) if config is None: - log.info(u'Session %s rejected: no configuration found for %s' % (session.call_id, session.request_uri)) + log.info('Session %s rejected: no configuration found for %s' % (session.call_id, session.request_uri)) session.reject(488) return stream_types = {'audio'} if config.enable_video: stream_types.add('video') streams = [stream for stream in session.proposed_streams if stream.type in stream_types] if not streams: log.info(u'Session %s rejected: invalid media' % session.call_id) session.reject(488) return handler = PlaybackHandler(config, session) handler.run() def incoming_subscription(self, request, data): request.reject(405) def incoming_referral(self, request, data): request.reject(405) def incoming_message(self, request, data): request.reject(405) +@implementer(IObserver) class PlaybackHandler(object): - implements(IObserver) def __init__(self, config, session): self.config = config self.session = session self.proc = None def run(self): notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) self.session.send_ring_indication() stream_types = {'audio'} if self.config.enable_video: stream_types.add('video') streams = [stream for stream in self.session.proposed_streams if stream.type in stream_types] reactor.callLater(self.config.answer_delay, self._accept_session, self.session, streams) def _accept_session(self, session, streams): if session.state == 'incoming': session.accept(streams) def _play(self): config = get_config('%s@%s' % (self.session.request_uri.user, self.session.request_uri.host)) if config is None: config = get_config('%s' % self.session.request_uri.user) try: audio_stream = next(stream for stream in self.session.streams if stream.type=='audio') except StopIteration: self.proc = None return player = WavePlayer(audio_stream.mixer, config.file) audio_stream.bridge.add(player) - log.info(u'Playing file %s for session %s' % (config.file, self.session.call_id)) + log.info('Playing file %s for session %s' % (config.file, self.session.call_id)) try: player.play().wait() except (ValueError, WavePlayerError) as e: - log.warning(u'Error playing file %s: %s' % (config.file, e)) + log.warning('Error playing file %s: %s' % (config.file, e)) except proc.ProcExit: pass finally: player.stop() self.proc = None audio_stream.bridge.remove(player) self.session.end() self.session = None def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionNewProposal(self, notification): if notification.data.originator == 'remote': session = notification.sender stream_types = {'audio'} if self.config.enable_video: stream_types.add('video') streams = [stream for stream in session.proposed_streams if stream.type in stream_types] if not streams: session.reject_proposal() return session.accept_proposal(streams) def _NH_SIPSessionDidRenegotiateStreams(self, notification): session = notification.sender for stream in notification.data.added_streams: log.info('Session %s added %s' % (session.call_id, stream.type)) for stream in notification.data.removed_streams: log.info('Session %s removed %s' % (session.call_id, stream.type)) if notification.data.added_streams and self.proc is None: self.proc = proc.spawn(self._play) if notification.data.removed_streams and not session.streams: session.end() def _NH_SIPSessionDidStart(self, notification): session = notification.sender log.info('Session %s started' % session.call_id) self.proc = proc.spawn(self._play) def _NH_SIPSessionDidFail(self, notification): session = notification.sender log.info('Session %s failed' % session.call_id) notification.center.remove_observer(self, sender=session) def _NH_SIPSessionWillEnd(self, notification): if self.proc: self.proc.kill() def _NH_SIPSessionDidEnd(self, notification): session = notification.sender log.info('Session %s ended' % session.call_id) notification.center.remove_observer(self, sender=session) diff --git a/sylk/applications/webrtcgateway/auth.py b/sylk/applications/webrtcgateway/auth.py index 1f2853e..93d9b05 100644 --- a/sylk/applications/webrtcgateway/auth.py +++ b/sylk/applications/webrtcgateway/auth.py @@ -1,79 +1,79 @@ import imaplib import socket import ssl from hashlib import md5 from eventlib.twistedutil import deferToGreenThread from .logger import log from .models import sylkrtc from .configuration import ExternalAuthConfig, get_auth_config class AuthHandler(object): def __init__(self, account_info, connection): if ExternalAuthConfig.enable: self.domain = account_info.id.partition('@')[2] self.user = account_info.id.partition('@')[0] self.password = account_info.password self.auth_conf = get_auth_config(self.domain) self.account_info = account_info self.connection = connection @property def type(self): if ExternalAuthConfig.enable: return self.auth_conf.auth_type else: return 'SIP' def authenticate(self, proxy): deferred = deferToGreenThread(self._authenticate, proxy) deferred.addCallback(self._auth_finished) def _authenticate(self, proxy): if self.auth_conf.auth_type == 'SIP': # for sip to continue we need to apply a ha1 hash self.account_info.password = md5('{u}:{d}:{p}'.format( u=self.user,d=self.domain,p=self.password)).hexdigest() return (True, proxy) elif self.auth_conf.auth_type == 'IMAP': try: imap_con = imaplib.IMAP4_SSL(self.auth_conf.imap_server) except ssl.SSLError: log.error('SSL handshake failed for server {server}. Check your ca config!'.format(server=self.auth_conf.imap_server)) return (False, None) log.debug('trying imap login for {user}'.format(user=self.user)) try: rv, data = imap_con.login(self.user, self.password) except imaplib.IMAP4.error: log.info('imap auth failed for {user}'.format(user=self.user)) return (False, None) return (True, proxy) def _auth_finished(self, ret): success, proxy = ret if success: # callout to janus self.account_info.janus_handle.register(self.account_info, proxy=proxy) else: if self.account_info.registration_state != 'failed': self.account_info.registration_state = 'failed' reason = 'external authentication failed (wrong username or password?)' self.connection.send(sylkrtc.AccountRegistrationFailedEvent( account=self.account_info.id, reason=reason)) log.info('registration for {account.id} failed: {reason}'.format( account=self.account_info, reason=reason)) # ca checks for imap4 ssl ca_cert_file = ExternalAuthConfig.imap_ca_cert_file def IMAP4SSL_open(self, host = '', port = imaplib.IMAP4_SSL_PORT): self.host = host self.port = port self.sock = socket.create_connection((host, port)) self.sslobj = ssl.wrap_socket(self.sock, keyfile=self.keyfile, certfile=self.certfile, server_side=False, cert_reqs=ssl.CERT_REQUIRED, ca_certs=ca_cert_file) self.file = self.sslobj.makefile('rb') -imaplib.IMAP4_SSL.__dict__['open']=IMAP4SSL_open +setattr(imaplib.IMAP4_SSL, 'open', IMAP4SSL_open) diff --git a/sylk/applications/webrtcgateway/factory.py b/sylk/applications/webrtcgateway/factory.py index f2b6e09..96f19eb 100644 --- a/sylk/applications/webrtcgateway/factory.py +++ b/sylk/applications/webrtcgateway/factory.py @@ -1,80 +1,80 @@ from application.notification import IObserver, NotificationCenter from application.python import Null from autobahn.twisted.websocket import WebSocketServerFactory -from zope.interface import implements +from zope.interface import implementer from .protocol import SylkWebSocketServerProtocol class VideoroomContainer(object): def __init__(self): self._rooms = set() self._id_map = {} # map videoroom.id -> videoroom and videoroom.uri -> videoroom def add(self, room): self._rooms.add(room) self._id_map[room.id] = self._id_map[room.uri] = room def discard(self, item): # item can be any of room, room.id or room.uri room = self._id_map[item] if item in self._id_map else item if item in self._rooms else None if room is not None: self._rooms.discard(room) self._id_map.pop(room.id, None) self._id_map.pop(room.uri, None) def remove(self, item): # item can be any of room, room.id or room.uri room = self._id_map[item] if item in self._id_map else item self._rooms.remove(room) self._id_map.pop(room.id) self._id_map.pop(room.uri) def pop(self, item): # item can be any of room, room.id or room.uri room = self._id_map[item] if item in self._id_map else item self._rooms.remove(room) self._id_map.pop(room.id) self._id_map.pop(room.uri) return room def clear(self): self._rooms.clear() self._id_map.clear() def __len__(self): return len(self._rooms) def __iter__(self): return iter(self._rooms) def __getitem__(self, key): return self._id_map[key] def __contains__(self, item): return item in self._id_map or item in self._rooms +@implementer(IObserver) class SylkWebSocketServerFactory(WebSocketServerFactory): - implements(IObserver) protocol = SylkWebSocketServerProtocol connections = set() videorooms = VideoroomContainer() def __init__(self, *args, **kw): super(SylkWebSocketServerFactory, self).__init__(*args, **kw) notification_center = NotificationCenter() notification_center.add_observer(self, name='JanusBackendDisconnected') def buildProtocol(self, addr): protocol = self.protocol() protocol.factory = self return protocol def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_JanusBackendDisconnected(self, notification): for conn in self.connections.copy(): conn.dropConnection(abort=True) self.videorooms.clear() diff --git a/sylk/applications/webrtcgateway/handler.py b/sylk/applications/webrtcgateway/handler.py index 98a17ca..46d4599 100644 --- a/sylk/applications/webrtcgateway/handler.py +++ b/sylk/applications/webrtcgateway/handler.py @@ -1,1908 +1,1907 @@ import hashlib import json import random import os import time import uuid from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null, limit from application.python.weakref import defaultweakobjectmap from application.system import makedirs, unlink from collections import deque from eventlib import coros, proc from itertools import count from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import SIPURI, FromHeader, ToHeader, Credentials, Message, RouteHeader from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.payloads.imdn import IMDNDocument, DeliveryNotification, DisplayNotification from sipsimple.streams import MediaStreamRegistry from sipsimple.streams.msrp.chat import CPIMPayload, CPIMParserError, ChatIdentity, CPIMHeader, CPIMNamespace from sipsimple.threading import run_in_thread, run_in_twisted_thread from sipsimple.threading.green import call_in_green_thread, run_in_green_thread from sipsimple.util import ISOTimestamp from shutil import copyfileobj, rmtree -from string import maketrans from twisted.internet import reactor from typing import Generic, Container, Iterable, Sized, TypeVar, Dict, Set, Optional, Union from werkzeug.exceptions import InternalServerError -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.session import Session from . import push from .configuration import GeneralConfig, get_room_config, ExternalAuthConfig, JanusConfig from .janus import JanusBackend, JanusError, JanusSession, SIPPluginHandle, VideoroomPluginHandle from .logger import ConnectionLogger, VideoroomLogger from .models import sylkrtc, janus from .storage import TokenStorage from .auth import AuthHandler class AccountInfo(object): # noinspection PyShadowingBuiltins def __init__(self, id, password, display_name=None, user_agent=None): self.id = id self.password = password self.display_name = display_name self.user_agent = user_agent self.registration_state = None self.janus_handle = None # type: Optional[SIPPluginHandle] self.contact_params = {} self.auth_handle = None self.auth_state = None @property def uri(self): return 'sip:' + self.id @property def user_data(self): return dict(username=self.uri, display_name=self.display_name, user_agent=self.user_agent, ha1_secret=self.password, contact_params=self.contact_params) class SessionPartyIdentity(object): def __init__(self, uri, display_name=None): self.uri = uri self.display_name = display_name # todo: might need to replace this auto-resetting descriptor with a timer in case we need to know when the slow link state expired class SlowLinkState(object): def __init__(self): self.slow_link = False self.last_reported = 0 class SlowLinkDescriptor(object): __timeout__ = 30 # 30 seconds def __init__(self): self.values = defaultweakobjectmap(SlowLinkState) def __get__(self, instance, owner): if instance is None: return self state = self.values[instance] if state.slow_link and time.time() - state.last_reported > self.__timeout__: state.slow_link = False return state.slow_link def __set__(self, instance, value): state = self.values[instance] if value: state.last_reported = time.time() state.slow_link = bool(value) def __delete__(self, instance): raise AttributeError('Attribute cannot be deleted') class SIPSessionInfo(object): slow_download = SlowLinkDescriptor() slow_upload = SlowLinkDescriptor() # noinspection PyShadowingBuiltins def __init__(self, id): self.id = id self.direction = None self.state = None self.account = None # type: Optional[AccountInfo] self.local_identity = None # type: Optional[SessionPartyIdentity] self.remote_identity = None # type: Optional[SessionPartyIdentity] self.janus_handle = None # type: Optional[SIPPluginHandle] self.slow_download = False self.slow_upload = False def init_outgoing(self, account, destination): self.account = account self.direction = 'outgoing' self.state = 'connecting' self.local_identity = SessionPartyIdentity(account.id) self.remote_identity = SessionPartyIdentity(destination) def init_incoming(self, account, originator, originator_display_name=''): self.account = account self.direction = 'incoming' self.state = 'connecting' self.local_identity = SessionPartyIdentity(account.id) self.remote_identity = SessionPartyIdentity(originator, originator_display_name) class VideoroomSessionInfo(object): slow_download = SlowLinkDescriptor() slow_upload = SlowLinkDescriptor() # noinspection PyShadowingBuiltins def __init__(self, id, owner, janus_handle): self.type = None # publisher / subscriber self.id = id self.owner = owner # type: ConnectionHandler self.janus_handle = janus_handle # type: VideoroomPluginHandle self.chat_handler = None # type: Optional[VideoroomChatHandler] self.account = None # type: Optional[AccountInfo] self.room = None # type: Optional[Videoroom] self.bitrate = None self.parent_session = None # type: Optional[VideoroomSessionInfo] # for subscribers this is their main session (the one used to join), for publishers is None self.publisher_id = None # janus publisher ID for publishers / publisher session ID for subscribers self.slow_download = False self.slow_upload = False self.feeds = PublisherFeedContainer() # keeps references to all the other participant's publisher feeds that we subscribed to def init_publisher(self, account, room): self.type = 'publisher' self.account = account self.room = room self.bitrate = room.config.max_bitrate self.chat_handler = VideoroomChatHandler(session=self) def init_subscriber(self, publisher_session, parent_session): assert publisher_session.type == parent_session.type == 'publisher' self.type = 'subscriber' self.publisher_id = publisher_session.id self.parent_session = parent_session self.account = parent_session.account self.room = parent_session.room self.bitrate = self.room.config.max_bitrate def __repr__(self): return '<{0.__class__.__name__}: type={0.type!r} id={0.id!r} janus_handle={0.janus_handle!r}>'.format(self) class PublisherFeedContainer(object): """A container for the other participant's publisher sessions that we have subscribed to""" def __init__(self): self._publishers = set() self._id_map = {} # map publisher.id -> publisher and publisher.publisher_id -> publisher def add(self, session): assert session not in self._publishers assert session.id not in self._id_map and session.publisher_id not in self._id_map self._publishers.add(session) self._id_map[session.id] = self._id_map[session.publisher_id] = session def discard(self, item): # item can be any of session, session.id or session.publisher_id session = self._id_map[item] if item in self._id_map else item if item in self._publishers else None if session is not None: self._publishers.discard(session) self._id_map.pop(session.id, None) self._id_map.pop(session.publisher_id, None) def remove(self, item): # item can be any of session, session.id or session.publisher_id session = self._id_map[item] if item in self._id_map else item self._publishers.remove(session) self._id_map.pop(session.id) self._id_map.pop(session.publisher_id) def pop(self, item): # item can be any of session, session.id or session.publisher_id session = self._id_map[item] if item in self._id_map else item self._publishers.remove(session) self._id_map.pop(session.id) self._id_map.pop(session.publisher_id) return session def clear(self): self._publishers.clear() self._id_map.clear() def __len__(self): return len(self._publishers) def __iter__(self): return iter(self._publishers) def __getitem__(self, key): return self._id_map[key] def __contains__(self, item): return item in self._id_map or item in self._publishers class Videoroom(object): def __init__(self, uri, audio, video): self.id = random.getrandbits(32) # janus needs numeric room names self.uri = uri self.audio = audio self.video = video self.config = get_room_config(uri) self.log = VideoroomLogger(self) self._active_participants = [] self._sessions = set() # type: Set[VideoroomSessionInfo] self._id_map = {} # type: Dict[Union[str, int], VideoroomSessionInfo] # map session.id -> session and session.publisher_id -> session self._shared_files = [] self._raised_hands = [] if self.config.record: makedirs(self.config.recording_dir, 0o755) self.log.info('created (recording on)') else: self.log.info('created') @property def active_participants(self): return self._active_participants @active_participants.setter def active_participants(self, participant_list): unknown_participants = set(participant_list).difference(self._id_map) if unknown_participants: raise ValueError('unknown participant session id: {}'.format(', '.join(unknown_participants))) if self._active_participants != participant_list: self._active_participants = participant_list self.log.info('active participants: {}'.format(', '.join(self._active_participants) or None)) self._update_bitrate() @property def raised_hands(self): return self._raised_hands @raised_hands.setter def raised_hands(self, session_id): if session_id in self._raised_hands: self.log.info('{session} lowers hand '.format(session=session_id)) self._raised_hands.remove(session_id) else: self.log.info('{session} raises hand '.format(session=session_id)) self._raised_hands.append(session_id) def add(self, session): assert session not in self._sessions assert session.publisher_id is not None assert session.publisher_id not in self._id_map and session.id not in self._id_map self._sessions.add(session) self._id_map[session.id] = self._id_map[session.publisher_id] = session self.log.info('{session.account.id} has joined'.format(session=session)) self._update_bitrate() if self._active_participants: session.owner.send(sylkrtc.VideoroomConfigureEvent(session=session.id, active_participants=self._active_participants, originator='videoroom')) if self._shared_files: session.owner.send(sylkrtc.VideoroomFileSharingEvent(session=session.id, files=self._shared_files)) if self._raised_hands: session.owner.send(sylkrtc.VideoroomRaisedHandsEvent(session=session.id, raised_hands=self._raised_hands)) # noinspection DuplicatedCode def discard(self, session): if session in self._sessions: self._sessions.discard(session) self._id_map.pop(session.id, None) self._id_map.pop(session.publisher_id, None) self.log.info('{session.account.id} has left'.format(session=session)) if session.id in self._active_participants: self._active_participants.remove(session.id) self.log.info('active participants: {}'.format(', '.join(self._active_participants) or None)) for session in self._sessions: session.owner.send(sylkrtc.VideoroomConfigureEvent(session=session.id, active_participants=self._active_participants, originator='videoroom')) self._update_bitrate() # noinspection DuplicatedCode def remove(self, session): self._sessions.remove(session) self._id_map.pop(session.id) self._id_map.pop(session.publisher_id) self.log.info('{session.account.id} has left'.format(session=session)) if session.id in self._active_participants: self._active_participants.remove(session.id) self.log.info('active participants: {}'.format(', '.join(self._active_participants) or None)) for session in self._sessions: session.owner.send(sylkrtc.VideoroomConfigureEvent(session=session.id, active_participants=self._active_participants, originator='videoroom')) self._update_bitrate() def clear(self): for session in self._sessions: self.log.info('{session.account.id} has left'.format(session=session)) self._active_participants = [] self._shared_files = [] self._sessions.clear() self._id_map.clear() def allow_uri(self, uri): config = self.config if config.access_policy == 'allow,deny': return config.allow.match(uri) and not config.deny.match(uri) else: return not config.deny.match(uri) or config.allow.match(uri) def add_file(self, upload_request): self._write_file(upload_request) def get_file(self, filename): path = os.path.join(self.config.filesharing_dir, filename) if os.path.exists(path): return path else: raise LookupError('file does not exist') @staticmethod def _fix_path(path): name, extension = os.path.splitext(path) for x in count(0, step=-1): path = '{}{}{}'.format(name, x or '', extension) if not os.path.exists(path) and not os.path.islink(path): return path @run_in_thread('file-io') def _write_file(self, upload_request): makedirs(self.config.filesharing_dir) path = self._fix_path(os.path.join(self.config.filesharing_dir, upload_request.shared_file.filename)) upload_request.shared_file.filename = os.path.basename(path) try: with open(path, 'wb') as output_file: copyfileobj(upload_request.content, output_file) except (OSError, IOError): upload_request.had_error = True unlink(path) self._write_file_done(upload_request) @run_in_twisted_thread def _write_file_done(self, upload_request): if upload_request.had_error: upload_request.deferred.errback(InternalServerError('could not save file')) else: self._shared_files.append(upload_request.shared_file) for session in self._sessions: session.owner.send(sylkrtc.VideoroomFileSharingEvent(session=session.id, files=[upload_request.shared_file])) upload_request.deferred.callback('OK') def cleanup(self): self._remove_files() @run_in_thread('file-io') def _remove_files(self): rmtree(self.config.filesharing_dir, ignore_errors=True) def _update_bitrate(self): if self._sessions: if self._active_participants: # todo: should we use max_bitrate / 2 or max_bitrate for each active participant if there are 2 active participants? active_participant_bitrate = self.config.max_bitrate // len(self._active_participants) other_participant_bitrate = 100000 self.log.debug('participant bitrate is {} (active) / {} (others)'.format(active_participant_bitrate, other_participant_bitrate)) for session in self._sessions: if session.id in self._active_participants: bitrate = active_participant_bitrate else: bitrate = other_participant_bitrate if session.bitrate != bitrate: session.bitrate = bitrate - session.janus_handle.message(janus.VideoroomUpdatePublisher(bitrate=bitrate), async=True) + session.janus_handle.message(janus.VideoroomUpdatePublisher(bitrate=bitrate), _async=True) else: bitrate = self.config.max_bitrate // limit(len(self._sessions) - 1, min=1) self.log.debug('participant bitrate is {}'.format(bitrate)) for session in self._sessions: if session.bitrate != bitrate: session.bitrate = bitrate - session.janus_handle.message(janus.VideoroomUpdatePublisher(bitrate=bitrate), async=True) + session.janus_handle.message(janus.VideoroomUpdatePublisher(bitrate=bitrate), _async=True) # todo: make Videoroom be a context manager that is retained/released on enter/exit and implement __nonzero__ to be different from __len__ # todo: so that a videoroom is not accidentally released by the last participant leaving while a new participant waits to join # todo: this needs a new model for communication with janus and the client that is pseudo-synchronous (uses green threads) def __len__(self): return len(self._sessions) def __iter__(self): return iter(self._sessions) def __getitem__(self, key): return self._id_map[key] def __contains__(self, item): return item in self._id_map or item in self._sessions SessionT = TypeVar('SessionT', SIPSessionInfo, VideoroomSessionInfo) class SessionContainer(Sized, Iterable[SessionT], Container[SessionT], Generic[SessionT]): def __init__(self): self._sessions = set() self._id_map = {} # map session.id -> session and session.janus_handle.id -> session def add(self, session): assert session not in self._sessions assert session.id not in self._id_map and session.janus_handle.id not in self._id_map self._sessions.add(session) self._id_map[session.id] = self._id_map[session.janus_handle.id] = session def discard(self, item): # item can be any of session, session.id or session.janus_handle.id session = self._id_map[item] if item in self._id_map else item if item in self._sessions else None if session is not None: self._sessions.discard(session) self._id_map.pop(session.id, None) self._id_map.pop(session.janus_handle.id, None) def remove(self, item): # item can be any of session, session.id or session.janus_handle.id session = self._id_map[item] if item in self._id_map else item self._sessions.remove(session) self._id_map.pop(session.id) self._id_map.pop(session.janus_handle.id) def pop(self, item): # item can be any of session, session.id or session.janus_handle.id session = self._id_map[item] if item in self._id_map else item self._sessions.remove(session) self._id_map.pop(session.id) self._id_map.pop(session.janus_handle.id) return session def clear(self): self._sessions.clear() self._id_map.clear() def __len__(self): return len(self._sessions) def __iter__(self): return iter(self._sessions) def __getitem__(self, key): return self._id_map[key] def __contains__(self, item): return item in self._id_map or item in self._sessions class OperationName(str): - __normalizer__ = maketrans('-', '_') + __normalizer__ = str.maketrans('-', '_') @property def normalized(self): return self.translate(self.__normalizer__) class Operation(object): __slots__ = 'type', 'name', 'data' __types__ = 'request', 'event' # noinspection PyShadowingBuiltins def __init__(self, type, name, data): if type not in self.__types__: raise ValueError("Can't instantiate class {.__class__.__name__} with unknown type: {!r}".format(self, type)) self.type = type self.name = OperationName(name) self.data = data class APIError(Exception): pass class GreenEvent(object): def __init__(self): self._event = coros.event() def set(self): if self._event.ready(): return self._event.send(True) def is_set(self): return self._event.ready() def clear(self): if self._event.ready(): self._event.reset() def wait(self): return self._event.wait() # noinspection PyPep8Naming +@implementer(IObserver) class ConnectionHandler(object): - implements(IObserver) janus = JanusBackend() def __init__(self, protocol): self.protocol = protocol self.device_id = hashlib.md5(protocol.peer).digest().encode('base64').rstrip('=\n') self.janus_session = None # type: Optional[JanusSession] self.accounts_map = {} # account ID -> account self.devices_map = {} # device ID -> account self.connections_map = {} # peer connection -> account self.account_handles_map = {} # Janus handle ID -> account self.sip_sessions = SessionContainer() # type: SessionContainer[SIPSessionInfo] # incoming and outgoing SIP sessions self.videoroom_sessions = SessionContainer() # type: SessionContainer[VideoroomSessionInfo] # publisher and subscriber sessions in video rooms self.ready_event = GreenEvent() self.resolver = DNSLookup() self.proc = proc.spawn(self._operations_handler) self.operations_queue = coros.queue() self.log = ConnectionLogger(self) self.state = None self._stop_pending = False self.decline_code = JanusConfig.decline_code or 486 @run_in_green_thread def start(self): self.state = 'starting' try: self.janus_session = JanusSession() except Exception as e: self.state = 'failed' self.log.warning('could not create session, disconnecting: %s' % e) if self._stop_pending: # if stop was already called it means we were already disconnected self.stop() else: - self.protocol.disconnect(3000, unicode(e)) + self.protocol.disconnect(3000, str(e)) else: self.state = 'started' self.ready_event.set() if self._stop_pending: self.stop() else: self.send(sylkrtc.ReadyEvent()) def stop(self): if self.state in (None, 'starting'): self._stop_pending = True return self.state = 'stopping' self._stop_pending = False if self.proc is not None: # Kill the operation's handler proc first, in order to not have any operations active while we cleanup. self.proc.kill() # Also proc.kill() will switch to another green thread, which is another reason to do it first so that self.proc = None # we do not switch to another green thread in the middle of the cleanup with a partially deleted handler if self.ready_event.is_set(): # Do not explicitly detach the janus plugin handles before destroying the janus session. Janus runs each request in a different # thread, so making detach and destroy request without waiting for the detach to finish can result in errors from race conditions. # Because we do not want to wait for them, we will rely instead on the fact that janus automatically detaches the plugin handles # when it destroys a session, so we only remove our event handlers and issue a destroy request for the session. - for account_info in self.accounts_map.values(): + for account_info in list(self.accounts_map.values()): if account_info.janus_handle is not None: self.janus.set_event_handler(account_info.janus_handle.id, None) for session in self.sip_sessions: if session.janus_handle is not None: self.janus.set_event_handler(session.janus_handle.id, None) for session in self.videoroom_sessions: if session.janus_handle is not None: self.janus.set_event_handler(session.janus_handle.id, None) if session.chat_handler is not None: notification_center = NotificationCenter() notification_center.remove_observer(self, sender=session.chat_handler) session.chat_handler.end() session.chat_handler = None if session in session.room: # We need to check if the room can be destroyed, else this will never happen reactor.callLater(2, call_in_green_thread, self._maybe_destroy_videoroom_after_disconnect, session.room) session.room.discard(session) session.feeds.clear() self.janus_session.destroy() # this automatically detaches all plugin handles associated with it, no need to manually do it # cleanup self.ready_event.clear() self.accounts_map.clear() self.devices_map.clear() self.connections_map.clear() self.account_handles_map.clear() self.sip_sessions.clear() self.videoroom_sessions.clear() self.janus_session = None self.protocol = None self.state = 'stopped' def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def handle_message(self, message): try: request = sylkrtc.SylkRTCRequest.from_message(message) except sylkrtc.ProtocolError as e: self.log.error(str(e)) except Exception as e: self.log.error('{request_type}: {exception!s}'.format(request_type=message['sylkrtc'], exception=e)) if 'transaction' in message: self.send(sylkrtc.ErrorResponse(transaction=message['transaction'], error=str(e))) else: operation = Operation(type='request', name=request.sylkrtc, data=request) self.operations_queue.send(operation) def send(self, message): if self.protocol is not None: self.protocol.sendMessage(json.dumps(message.__data__)) # internal methods (not overriding / implementing the protocol API) def _cleanup_session(self, session): # should only be called from a green thread. if self.janus_session is None: # The connection was closed, there is noting to do return if session in self.sip_sessions: self.sip_sessions.remove(session) if session.direction == 'outgoing': # Destroy plugin handle for outgoing sessions. For incoming ones it's the same as the account handle, so don't session.janus_handle.detach() def _cleanup_videoroom_session(self, session): # should only be called from a green thread. if self.janus_session is None: # The connection was closed, there is noting to do return if session in self.videoroom_sessions: self.videoroom_sessions.remove(session) if session.type == 'publisher': notification_center = NotificationCenter() notification_center.remove_observer(self, sender=session.chat_handler) session.room.discard(session) session.feeds.clear() session.janus_handle.detach() session.chat_handler.end() self._maybe_destroy_videoroom(session.room) else: session.parent_session.feeds.discard(session.publisher_id) session.janus_handle.detach() def _maybe_destroy_videoroom(self, videoroom): # should only be called from a green thread. if self.protocol is None or self.janus_session is None: # The connection was closed, there is nothing to do return if videoroom in self.protocol.factory.videorooms and not videoroom: self.protocol.factory.videorooms.remove(videoroom) videoroom.cleanup() with VideoroomPluginHandle(self.janus_session, event_handler=self._handle_janus_videoroom_event) as videoroom_handle: videoroom_handle.destroy(room=videoroom.id) videoroom.log.info('destroyed') def _maybe_destroy_videoroom_after_disconnect(self, videoroom): # should only be called from a green thread. if self.protocol is None and not videoroom: videoroom.cleanup() videoroom.log.info('destroyed') def _lookup_sip_proxy(self, uri): # The proxy dance: Sofia-SIP seems to do a DNS lookup per SIP message when a domain is passed # as the proxy, so do the resolution ourselves and give it pre-resolver proxy URL. Since we use # caching to avoid long delays, we randomize the results matching the highest priority route's # transport. proxy = GeneralConfig.outbound_sip_proxy if proxy is not None: sip_uri = SIPURI(host=proxy.host, port=proxy.port, parameters={'transport': proxy.transport}) else: sip_uri = SIPURI.parse('sip:%s' % uri) settings = SIPSimpleSettings() try: routes = self.resolver.lookup_sip_proxy(sip_uri, settings.sip.transport_list).wait() except DNSLookupError as e: raise DNSLookupError('DNS lookup error: {exception!s}'.format(exception=e)) if not routes: raise DNSLookupError('DNS lookup error: no results found') route = random.choice([r for r in routes if r.transport == routes[0].transport]) self.log.debug('DNS lookup for SIP proxy for {} yielded {}'.format(uri, route)) # Build a proxy URI Sofia-SIP likes return 'sips:{route.address}:{route.port}'.format(route=route) if route.transport == 'tls' else str(route.uri) def _callid_to_uuid(self, callid): hexa = hashlib.md5(callid.encode()).hexdigest() uuidv4 = '%s-%s-%s-%s-%s' % (hexa[:8], hexa[8:12], hexa[12:16], hexa[16:20], hexa[20:]) return uuidv4 def _lookup_sip_target_route(self, uri): # TODO - add support for outbound proxy setting from server configuration -adi sip_uri = SIPURI.parse('sip:%s' % uri) settings = SIPSimpleSettings() try: routes = self.resolver.lookup_sip_proxy(sip_uri, settings.sip.transport_list).wait() except DNSLookupError as e: raise DNSLookupError('DNS lookup error: {exception!s}'.format(exception=e)) if not routes: raise DNSLookupError('DNS lookup error: no results found') route = random.choice([r for r in routes if r.transport == routes[0].transport]) self.log.debug('DNS lookup for SIP message proxy for {} yielded {}'.format(uri, route)) return route def _send_sip_message(self, account, uri, message_id, content, content_type='text/plain', timestamp=None, add_disposition=True): route = self._lookup_sip_target_route(uri) sip_uri = SIPURI.parse('sip:%s' % uri) if route: identity = str(account.uri) if account.display_name: identity = '"%s" <%s>' % (account.display_name, identity) self.log.debug("sending message from '%s' to '%s' using proxy %s" % (identity, uri, route)) from_uri = SIPURI.parse(account.uri) content = content.encode('utf-8') ns = CPIMNamespace('urn:ietf:params:imdn', 'imdn') additional_headers = [CPIMHeader('Message-ID', ns, message_id)] if add_disposition: additional_headers.append(CPIMHeader('Disposition-Notification', ns, 'positive-delivery, display')) payload = CPIMPayload(content, content_type, charset='utf-8', sender=ChatIdentity(from_uri, account.display_name), recipients=[ChatIdentity(sip_uri, None)], timestamp=timestamp if timestamp is not None else str(ISOTimestamp.now()), additional_headers=additional_headers) payload, content_type = payload.encode() credentials = Credentials(username=from_uri.user, password=account.password.encode('utf-8'), digest=True) message_request = Message(FromHeader(from_uri, account.display_name), ToHeader(sip_uri), RouteHeader(route.uri), content_type, payload, credentials=credentials) notification_center = NotificationCenter() notification_center.add_observer(self, sender=message_request) #self._message_queue.append((message_id, content, content_type)) message_request.send() def _handle_janus_sip_event(self, event): operation = Operation(type='event', name='janus-sip', data=event) self.operations_queue.send(operation) def _handle_janus_videoroom_event(self, event): operation = Operation(type='event', name='janus-videoroom', data=event) self.operations_queue.send(operation) def _operations_handler(self): self.ready_event.wait() while True: operation = self.operations_queue.wait() handler = getattr(self, '_OH_' + operation.type) handler(operation) del operation, handler def _OH_request(self, operation): handler = getattr(self, '_RH_' + operation.name.normalized) request = operation.data try: handler(request) except (APIError, DNSLookupError, JanusError) as e: self.log.error('{operation.name}: {exception!s}'.format(operation=operation, exception=e)) self.send(sylkrtc.ErrorResponse(transaction=request.transaction, error=str(e))) except Exception as e: self.log.exception('{operation.type} {operation.name}: {exception!s}'.format(operation=operation, exception=e)) self.send(sylkrtc.ErrorResponse(transaction=request.transaction, error='Internal error')) else: self.send(sylkrtc.AckResponse(transaction=request.transaction)) def _OH_event(self, operation): handler = getattr(self, '_EH_' + operation.name.normalized) try: handler(operation.data) except Exception as e: self.log.exception('{operation.type} {operation.name}: {exception!s}'.format(operation=operation, exception=e)) # Request handlers def _RH_ping(self, request): pass def _RH_account_add(self, request): if request.account in self.accounts_map: raise APIError('Account {request.account} already added'.format(request=request)) # check if domain is acceptable domain = request.account.partition('@')[2] if not {'*', domain}.intersection(GeneralConfig.sip_domains): raise APIError('SIP domain not allowed: %s' % domain) # Create and store our mapping account_info = AccountInfo(request.account, request.password, request.display_name, request.user_agent) # get the auth config for domain account_info.auth_handle = AuthHandler(account_info, self) self.accounts_map[account_info.id] = account_info self.devices_map[self.device_id] = account_info.id self.connections_map[self.protocol.peer] = account_info.id self.log.info('added using {request.user_agent}'.format(request=request)) def _RH_account_remove(self, request): try: account_info = self.accounts_map.pop(request.account) except KeyError: raise APIError('Unknown account specified for remove: {request.account}'.format(request=request)) # cleanup in case the client didn't unregister before removing the account if account_info.janus_handle is not None: account_info.janus_handle.detach() self.account_handles_map.pop(account_info.janus_handle.id) self.log.info('removed') try: del(self.devices_map[request.account]) except KeyError: pass try: del(self.connections_map[request.account]) except KeyError: pass def _RH_account_register(self, request): try: account_info = self.accounts_map[request.account] except KeyError: raise APIError('Unknown account specified for register: {request.account}'.format(request=request)) proxy = self._lookup_sip_proxy(request.account) if account_info.janus_handle is not None: # Destroy the existing plugin handle account_info.janus_handle.detach() self.account_handles_map.pop(account_info.janus_handle.id) account_info.janus_handle = None # Create a plugin handle account_info.janus_handle = SIPPluginHandle(self.janus_session, event_handler=self._handle_janus_sip_event) self.account_handles_map[account_info.janus_handle.id] = account_info if ExternalAuthConfig.enable: account_info.auth_handle.authenticate(proxy) else: account_info.janus_handle.register(account_info, proxy=proxy) self.log.info('registering to SIP Proxy {proxy}...'.format(proxy=proxy)) def _RH_account_unregister(self, request): try: account_info = self.accounts_map[request.account] except KeyError: raise APIError('Unknown account specified for unregister: {request.account}'.format(request=request)) if account_info.janus_handle is not None: account_info.janus_handle.detach() self.account_handles_map.pop(account_info.janus_handle.id) account_info.janus_handle = None if 'pn_tok' in account_info.contact_params: storage = TokenStorage() storage.remove(request.account, account_info.contact_params['pn_tok']) self.log.info('registered') def _RH_account_devicetoken(self, request): if request.account not in self.accounts_map: raise APIError('Unknown account specified for token: {request.account}'.format(request=request)) if request.token is not None: account_info = self.accounts_map[request.account] account_info.contact_params = { 'pn_app': request.app, 'pn_tok': request.token, 'pn_type': request.platform, 'pn_device': request.device, 'pn_silent': str(int(request.silent is True)) # janus expects a string } storage = TokenStorage() storage.add(request.account, account_info.contact_params, account_info.user_agent) self.log.info('added token on {request.platform} device {request.device})'.format(request=request)) def _RH_account_message(self, request): try: account_info = self.accounts_map[request.account] except KeyError: raise APIError('Unknown account specified: {request.account}'.format(request=request)) uri = request.uri content_type = request.content_type content = request.content if content_type.startswith('text') else request.content.encode('latin1') message_id = request.message_id.encode('ascii') timestamp = request.timestamp self.log.info('sending message ({content_type}) to: {uri}'.format(content_type=content_type, uri=uri)) self._send_sip_message(account_info, uri, message_id, content, content_type, timestamp=timestamp) def _RH_account_disposition_notification(self, request): try: account_info = self.accounts_map[request.account] except KeyError: raise APIError('Unknown account specified: {request.account}'.format(request=request)) uri = request.uri message_id = request.message_id.encode('ascii') state = request.state if state == 'delivered': notification = DeliveryNotification(state) elif state == 'displayed': notification = DisplayNotification(state) content = IMDNDocument.create(message_id=message_id, datetime=request.timestamp, recipient_uri=uri, notification=notification) self.log.info('sending IMDN message ({status}) to: {uri}'.format(status=state, uri=uri)) self._send_sip_message(account_info, uri, str(uuid.uuid4()), content, IMDNDocument.content_type, add_disposition=False) def _RH_session_create(self, request): if request.session in self.sip_sessions: raise APIError('Session ID {request.session} already in use'.format(request=request)) try: account_info = self.accounts_map[request.account] except KeyError: raise APIError('Unknown account specified: {request.account}'.format(request=request)) proxy = self._lookup_sip_proxy(request.uri) # Create a new plugin handle and 'register' it, without actually doing so janus_handle = SIPPluginHandle(self.janus_session, event_handler=self._handle_janus_sip_event) try: janus_handle.call(account_info, uri=request.uri, sdp=request.sdp, proxy=proxy) except Exception: janus_handle.detach() raise session_info = SIPSessionInfo(request.session) session_info.janus_handle = janus_handle session_info.init_outgoing(account_info, request.uri) self.sip_sessions.add(session_info) self.log.info('outgoing session {request.session} to {request.uri}'.format(request=request)) def _RH_session_answer(self, request): try: session_info = self.sip_sessions[request.session] except KeyError: raise APIError('Unknown session {request.session}'.format(request=request)) if session_info.direction != 'incoming': raise APIError('Cannot answer outgoing session {request.session}'.format(request=request)) if session_info.state != 'connecting': raise APIError('Invalid state for answering session {session.id}: {session.state}'.format(session=session_info)) session_info.janus_handle.accept(sdp=request.sdp) self.log.info('incoming session {session.id} answered'.format(session=session_info)) def _RH_session_trickle(self, request): try: session_info = self.sip_sessions[request.session] except KeyError: raise APIError('Unknown session {request.session}'.format(request=request)) if session_info.state == 'terminated': raise APIError('Session {request.session} is terminated'.format(request=request)) session_info.janus_handle.trickle(request.candidates) if not request.candidates: self.log.debug('session {session.id} negotiated ICE'.format(session=session_info)) def _RH_session_terminate(self, request): try: session_info = self.sip_sessions[request.session] except KeyError: raise APIError('Unknown session {request.session}'.format(request=request)) if session_info.state not in ('connecting', 'progress', 'early_media', 'accepted', 'established'): raise APIError('Invalid state for terminating session {session.id}: {session.state}'.format(session=session_info)) if session_info.direction == 'incoming' and session_info.state == 'connecting': session_info.janus_handle.decline(self.decline_code) else: session_info.janus_handle.hangup() self.log.info('{session.direction} session {session.id} will terminate'.format(session=session_info)) def _RH_videoroom_join(self, request): if request.session in self.videoroom_sessions: raise APIError('Session ID {request.session} already in use'.format(request=request)) try: account_info = self.accounts_map[request.account] except KeyError: raise APIError('Unknown account specified: {request.account}'.format(request=request)) try: videoroom = self.protocol.factory.videorooms[request.uri] except KeyError: videoroom = Videoroom(request.uri, request.audio, request.video) self.protocol.factory.videorooms.add(videoroom) if not videoroom.allow_uri(request.account): self._maybe_destroy_videoroom(videoroom) raise APIError('is not allowed to join room {request.uri}'.format(request=request)) if ('m=video' in request.sdp and 'm=audio' in request.sdp): media = 'audio/video' elif ('m=video' in request.sdp): media = 'video only' elif ('m=audio' in request.sdp): media = 'audio only' else: media = 'unknown' try: videoroom_handle = VideoroomPluginHandle(self.janus_session, event_handler=self._handle_janus_videoroom_event) try: try: videoroom_handle.create(room=videoroom.id, config=videoroom.config, publishers=10) except JanusError as e: if e.code != 427: # 427 means room already exists raise else: self.log.info('created room {room}'.format(room=request.uri)) videoroom_handle.join(room=videoroom.id, sdp=request.sdp, display_name=account_info.display_name, audio=videoroom.audio, video=videoroom.video) except Exception: videoroom_handle.detach() raise except Exception: self._maybe_destroy_videoroom(videoroom) raise videoroom_session = VideoroomSessionInfo(request.session, owner=self, janus_handle=videoroom_handle) videoroom_session.init_publisher(account=account_info, room=videoroom) self.log.info('publish {media} to room {room}'.format(room=request.uri, media=media)) self.videoroom_sessions.add(videoroom_session) notification_center = NotificationCenter() notification_center.add_observer(self, sender=videoroom_session.chat_handler) videoroom_session.chat_handler.start() self.send(sylkrtc.VideoroomSessionProgressEvent(session=videoroom_session.id)) def _RH_videoroom_leave(self, request): try: videoroom_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) videoroom_session.janus_handle.leave() self.send(sylkrtc.VideoroomSessionTerminatedEvent(session=videoroom_session.id)) # safety net in case we do not get any answer for the leave request # todo: to be adjusted later after pseudo-synchronous communication with janus is implemented reactor.callLater(2, call_in_green_thread, self._cleanup_videoroom_session, videoroom_session) self.log.debug('leaving room {session.room.uri}'.format(session=videoroom_session)) def _RH_videoroom_configure(self, request): try: videoroom_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) videoroom = videoroom_session.room # todo: should we send out events if the active participant list did not change? try: videoroom.active_participants = request.active_participants except ValueError as e: raise APIError(str(e)) for session in videoroom: session.owner.send(sylkrtc.VideoroomConfigureEvent(session=session.id, active_participants=videoroom.active_participants, originator=request.session)) def _RH_videoroom_feed_attach(self, request): # sent when a feed is subscribed for a given publisher if request.feed in self.videoroom_sessions: raise APIError('Video room session ID {request.feed} already in use'.format(request=request)) try: base_session = self.videoroom_sessions[request.session] # our 'base' session (the one used to join and publish) except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) try: publisher_session = base_session.room[request.publisher] # the publisher's session (the one we want to subscribe to) except KeyError: raise APIError('Unknown publisher room session to attach to: {request.publisher}'.format(request=request)) if publisher_session.publisher_id is None: raise APIError('Video room session {session.id} does not have a publisher ID'.format(session=publisher_session)) videoroom_handle = VideoroomPluginHandle(self.janus_session, event_handler=self._handle_janus_videoroom_event) try: videoroom_handle.feed_attach(room=base_session.room.id, feed=publisher_session.publisher_id, offer_audio=base_session.room.audio, offer_video=base_session.room.video) except Exception: videoroom_handle.detach() raise videoroom_session = VideoroomSessionInfo(request.feed, owner=self, janus_handle=videoroom_handle) videoroom_session.init_subscriber(publisher_session, parent_session=base_session) self.videoroom_sessions.add(videoroom_session) base_session.feeds.add(publisher_session) self.log.debug('subscribe to {account} in room {session.room.uri} {feeds}'.format(account=publisher_session.account.id, session=videoroom_session, feeds=len(base_session.feeds))) def _RH_videoroom_feed_answer(self, request): try: videoroom_session = self.videoroom_sessions[request.feed] except KeyError: raise APIError('Unknown room session: {request.feed}'.format(request=request)) if videoroom_session.parent_session.id != request.session: raise APIError('{request.feed} is not an attached feed of {request.session}'.format(request=request)) if ('m=video' in request.sdp and 'm=audio' in request.sdp): media = 'audio/video' elif ('m=video' in request.sdp): media = 'video only' elif ('m=audio' in request.sdp): media = 'audio only' else: media = 'unknown' self.log.debug('{media} media accepted by room {session.room.uri}'.format(media=media, session=videoroom_session)) videoroom_session.janus_handle.feed_start(sdp=request.sdp) def _RH_videoroom_feed_detach(self, request): try: videoroom_session = self.videoroom_sessions[request.feed] except KeyError: raise APIError('Unknown room session to detach: {request.feed}'.format(request=request)) if videoroom_session.parent_session.id != request.session: raise APIError('{request.feed} is not an attached feed of {request.session}'.format(request=request)) videoroom_session.janus_handle.feed_detach() # safety net in case we do not get any answer for the feed_detach request # todo: to be adjusted later after pseudo-synchronous communication with janus is implemented self.log.debug('unsubscribe from {account} in room {session.room.uri}'.format(account=videoroom_session.room[videoroom_session.publisher_id].account.id, session=videoroom_session)) reactor.callLater(2, call_in_green_thread, self._cleanup_videoroom_session, videoroom_session) def _RH_videoroom_invite(self, request): try: base_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) room = base_session.room participants = set(request.participants) originator = sylkrtc.SIPIdentity(uri=base_session.account.id, display_name=base_session.account.display_name) session_id = str(random.getrandbits(32)) event = sylkrtc.AccountConferenceInviteEvent(account='placeholder', room=room.uri, originator=originator, session_id=self._callid_to_uuid(session_id)) for protocol in self.protocol.factory.connections.difference([self.protocol]): connection_handler = protocol.connection_handler for account in participants.intersection(connection_handler.accounts_map): event.account = account connection_handler.send(event) room.log.info('invitation from %s for %s', originator.uri, account) room.log.debug('invitation from %s for %s with session-id %s', originator.uri, account, session_id) connection_handler.log.info('received an invitation from %s for %s to join room %s', originator.uri, account, room.uri) for participant in participants: push.conference_invite(originator=originator, destination=participant, room=room.uri, call_id=session_id, audio=room.audio, video=room.video) def _RH_videoroom_session_trickle(self, request): try: videoroom_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) videoroom_session.janus_handle.trickle(request.candidates) if not request.candidates and videoroom_session.type == 'publisher': self.log.debug('ICE negotiation to room {session.room.uri} completed'.format(session=videoroom_session)) def _RH_videoroom_session_update(self, request): try: videoroom_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) options = request.options.__data__ if options: videoroom_session.janus_handle.update_publisher(options) modified = ', '.join('{}={}'.format(key, options[key]) for key in options) media = 'video' try: has_video = options['video'] except KeyError: pass else: if not has_video: media = 'audio only' self.log.info('switched to {media} media to {account} in room {session.room.uri}'.format(account=videoroom_session.room[videoroom_session.publisher_id].account.id, session=videoroom_session, media=media)) def _RH_videoroom_message(self, request): try: videoroom_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) content_type = request.content_type content = request.content if content_type.startswith('text') else request.content.encode('latin1') message_id = request.message_id.encode('ascii') videoroom_session.chat_handler.send_message(message_id, content, content_type) def _RH_videoroom_composing_indication(self, request): try: videoroom_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) videoroom_session.chat_handler.send_composing_indication(request.state, request.refresh) def _RH_videoroom_mute_audio_participants(self, request): try: videoroom_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) videoroom = videoroom_session.room for session in videoroom: session.owner.send(sylkrtc.VideoroomMuteAudioEvent(session=session.id, originator=request.session)) def _RH_videoroom_toggle_hand(self, request): try: videoroom_session = self.videoroom_sessions[request.session] except KeyError: raise APIError('Unknown room session: {request.session}'.format(request=request)) videoroom = videoroom_session.room if request.session_id: request_session = request.session_id else: request_session = request.session videoroom.raised_hands = request_session for session in videoroom: session.owner.send(sylkrtc.VideoroomRaisedHandsEvent(session=session.id, raised_hands=videoroom.raised_hands)) # Event handlers def _EH_janus_sip(self, event): if isinstance(event, janus.PluginEvent): event_id = event.plugindata.data.__id__ try: handler = getattr(self, '_EH_janus_' + '_'.join(event_id)) except AttributeError: self.log.warning('unhandled Janus SIP event: {event_name}'.format(event_name=event_id[-1])) else: self.log.debug('janus SIP event: {event_name} (handle_id={event.sender})'.format(event=event, event_name=event_id[-1])) handler(event) else: # janus.CoreEvent try: handler = getattr(self, '_EH_janus_sip_' + event.janus) except AttributeError: self.log.warning('unhandled Janus SIP event: {event.janus}'.format(event=event)) else: self.log.debug('janus SIP event: {event.janus} (handle_id={event.sender})'.format(event=event)) handler(event) def _EH_janus_sip_error(self, event): # fixme: implement error handling self.log.error('got SIP error event: {}'.format(event.__data__)) handle_id = event.sender if handle_id in self.sip_sessions: pass # this is a session related event elif handle_id in self.account_handles_map: pass # this is an account related event def _EH_janus_sip_webrtcup(self, event): try: session_info = self.sip_sessions[event.sender] except KeyError: self.log.warning('could not find SIP session with handle ID {event.sender} for webrtcup event'.format(event=event)) return session_info.state = 'established' self.send(sylkrtc.SessionEstablishedEvent(session=session_info.id)) self.log.info('{session.direction} session {session.id} established'.format(session=session_info)) def _EH_janus_sip_hangup(self, event): try: session_info = self.sip_sessions[event.sender] except KeyError: return if session_info.state != 'terminated': session_info.state = 'terminated' reason = event.reason or 'unspecified reason' self.send(sylkrtc.SessionTerminatedEvent(session=session_info.id, reason=reason)) self.log.info('{session.direction} session {session.id} terminated ({reason})'.format(session=session_info, reason=reason)) self._cleanup_session(session_info) def _EH_janus_sip_slowlink(self, event): try: session_info = self.sip_sessions[event.sender] except KeyError: self.log.warning('could not find SIP session with handle ID {event.sender} for slowlink event'.format(event=event)) return if event.uplink: # uplink is from janus' point of view if not session_info.slow_download: self.log.debug('poor download connectivity for session {session.id}'.format(session=session_info)) session_info.slow_download = True else: if not session_info.slow_upload: self.log.debug('poor upload connectivity for session {session.id}'.format(session=session_info)) session_info.slow_upload = True def _EH_janus_sip_media(self, event): pass def _EH_janus_sip_detached(self, event): pass def _EH_janus_sip_event_registering(self, event): try: account_info = self.account_handles_map[event.sender] except KeyError: self.log.warning('could not find account with handle ID {event.sender} for registering event'.format(event=event)) return if account_info.registration_state != 'registering': account_info.registration_state = 'registering' self.send(sylkrtc.AccountRegisteringEvent(account=account_info.id)) def _EH_janus_sip_event_registered(self, event): if event.sender in self.sip_sessions: # skip 'registered' events from outgoing session handles return try: account_info = self.account_handles_map[event.sender] except KeyError: self.log.warning('could not find account with handle ID {event.sender} for registered event'.format(event=event)) return if account_info.registration_state != 'registered': account_info.registration_state = 'registered' self.send(sylkrtc.AccountRegisteredEvent(account=account_info.id)) self.log.info('registered') def _EH_janus_sip_event_registration_failed(self, event): try: account_info = self.account_handles_map[event.sender] except KeyError: self.log.warning('could not find account with handle ID {event.sender} for registration failed event'.format(event=event)) return if account_info.registration_state != 'failed': account_info.registration_state = 'failed' reason = '{result.code} {result.reason}'.format(result=event.plugindata.data.result) self.send(sylkrtc.AccountRegistrationFailedEvent(account=account_info.id, reason=reason)) self.log.info('registration failed: {reason}'.format(reason=reason)) def _EH_janus_sip_event_incomingcall(self, event): try: account_info = self.account_handles_map[event.sender] except KeyError: self.log.warning('could not find account with handle ID {event.sender} for incoming call event'.format(event=event)) return assert event.jsep is not None data = event.plugindata.data.result # type: janus.SIPResultIncomingCall call_id = event.plugindata.data.call_id originator = sylkrtc.SIPIdentity(uri=data.username, display_name=data.displayname) session = SIPSessionInfo(self._callid_to_uuid(call_id)) session.janus_handle = account_info.janus_handle session.init_incoming(account_info, originator.uri, originator.display_name) self.sip_sessions.add(session) self.send(sylkrtc.AccountIncomingSessionEvent(account=account_info.id, session=session.id, originator=originator, sdp=event.jsep.sdp, call_id=call_id)) self.log.info('incoming session {session.id} from {session.remote_identity.uri!s}'.format(session=session)) def _EH_janus_sip_event_missed_call(self, event): try: account_info = self.account_handles_map[event.sender] except KeyError: self.log.warning('could not find account with handle ID {event.sender} for missed call event'.format(event=event)) return data = event.plugindata.data.result # type: janus.SIPResultMissedCall originator = sylkrtc.SIPIdentity(uri=data.caller, display_name=data.displayname) self.send(sylkrtc.AccountMissedSessionEvent(account=account_info.id, originator=originator)) self.log.info('missed incoming call from {originator.uri}'.format(originator=originator)) def _EH_janus_sip_event_calling(self, event): try: session_info = self.sip_sessions[event.sender] except KeyError: self.log.warning('could not find SIP session with handle ID {event.sender} for calling event'.format(event=event)) return session_info.state = 'progress' self.send(sylkrtc.SessionProgressEvent(session=session_info.id)) self.log.debug('{session.direction} session {session.id} state: {session.state}'.format(session=session_info)) def _EH_janus_sip_event_accepted(self, event): try: session_info = self.sip_sessions[event.sender] except KeyError: self.log.warning('could not find SIP session with handle ID {event.sender} for accepted event'.format(event=event)) return if session_info.state == 'established': # We had early media session_info.state = 'accepted' self.send(sylkrtc.SessionAcceptedEvent(session=session_info.id)) self.log.debug('{session.direction} session {session.id} state: {session.state}'.format(session=session_info)) return session_info.state = 'accepted' if session_info.direction == 'outgoing': assert event.jsep is not None self.send(sylkrtc.SessionAcceptedEvent(session=session_info.id, sdp=event.jsep.sdp, call_id=event.plugindata.data.call_id)) else: self.send(sylkrtc.SessionAcceptedEvent(session=session_info.id)) self.log.debug('{session.direction} session {session.id} state: {session.state}'.format(session=session_info)) def _EH_janus_sip_event_hangup(self, event): try: session_info = self.sip_sessions[event.sender] except KeyError: self.log.warning('could not find SIP session with handle ID {event.sender} for hangup event'.format(event=event)) return if session_info.state != 'terminated': session_info.state = 'terminated' data = event.plugindata.data.result # type: janus.SIPResultHangup reason = '{0.code} {0.reason}'.format(data) self.send(sylkrtc.SessionTerminatedEvent(session=session_info.id, reason=reason)) if session_info.direction == 'incoming' and data.code == 487: # incoming call was cancelled -> missed self.send(sylkrtc.AccountMissedSessionEvent(account=session_info.account.id, originator=session_info.remote_identity.__dict__)) if data.code >= 300: self.log.info('{session.direction} session {session.id} terminated ({reason})'.format(session=session_info, reason=reason)) else: self.log.info('{session.direction} session {session.id} terminated'.format(session=session_info)) self._cleanup_session(session_info) def _EH_janus_sip_event_declining(self, event): pass def _EH_janus_sip_event_hangingup(self, event): pass def _EH_janus_sip_event_proceeding(self, event): pass def _EH_janus_sip_event_progress(self, event): if (event.jsep): try: session_info = self.sip_sessions[event.sender] except KeyError: self.log.warning('could not find SIP session with handle ID {event.sender} for progress event'.format(event=event)) return session_info.state = 'early_media' self.log.info('{session.direction} session {session.id} has early media'.format(session=session_info)) self.send(sylkrtc.SessionEarlyMediaEvent(session=session_info.id, sdp=event.jsep.sdp, call_id=event.plugindata.data.call_id)) self.log.debug('{session.direction} session {session.id} state: {session.state}'.format(session=session_info)) def _EH_janus_sip_event_ringing(self, event): pass def _EH_janus_sip_event_message(self, event): try: account_info = self.account_handles_map[event.sender] except KeyError: self.log.warning('could not find account with handle ID {event.sender} for message event'.format(event=event)) return data = event.plugindata.data.result # type: janus.SIPResultMessage cpim_message = None if data.content_type == "application/im-iscomposing+xml": return elif data.content_type == "message/cpim": try: - content = data.content if isinstance(data.content, unicode) else data.content.decode('latin1') # preserve > + content = data.content if isinstance(data.content, str) else data.content.decode('latin1') # preserve > cpim_message = CPIMPayload.decode(content.encode('utf-8')) except CPIMParserError: self.log.info('message rejected: CPIM parse error') return else: body = cpim_message.content content_type = cpim_message.content_type sender = cpim_message.sender or FromHeader(SIPURI.parse('{}'.format(data.sender)), data.displayname) disposition = next(([item.strip() for item in header.value.split(',')] for header in cpim_message.additional_headers if header.name == 'Disposition-Notification'), None) message_id = next((header.value for header in cpim_message.additional_headers if header.name == 'Message-ID'), None) else: body = data.content content_type = data.content_type sender = FromHeader(SIPURI.parse('{}'.format(data.sender)), data.displayname) disposition = None message_id = str(uuid.uuid4()) sender = sylkrtc.SIPIdentity(uri=str(sender.uri), display_name=sender.display_name) timestamp = str(cpim_message.timestamp) if cpim_message is not None and cpim_message.timestamp is not None else str(ISOTimestamp.now()) if content_type == IMDNDocument.content_type: document = IMDNDocument.parse(body) imdn_message_id = document.message_id.value imdn_status = document.notification.status.__str__() self.log.info('received IMDN message ({status}) from: {originator.uri}'.format(status=imdn_status, originator=sender)) self.send(sylkrtc.AccountDispositionNotificationEvent(account=account_info.id, state=imdn_status, message_id=imdn_message_id, timestamp=timestamp, code=200, reason='')) else: self.log.info('received message ({content_type}) from: {originator.uri}'.format(content_type=content_type, originator=sender)) self.send(sylkrtc.AccountMessageEvent(account=account_info.id, sender=sender, content=body, content_type=content_type, timestamp=timestamp, disposition_notification=disposition, message_id=message_id)) def _EH_janus_videoroom(self, event): if isinstance(event, janus.PluginEvent): event_id = event.plugindata.data.__id__ try: handler = getattr(self, '_EH_janus_' + '_'.join(event_id)) except AttributeError: self.log.warning('unhandled Janus videoroom event: {event_name}'.format(event_name=event_id[-1])) else: self.log.debug('janus videoroom event: {event_name} (handle_id={event.sender})'.format(event=event, event_name=event_id[-1])) handler(event) else: # janus.CoreEvent try: handler = getattr(self, '_EH_janus_videoroom_' + event.janus) except AttributeError: self.log.warning('unhandled Janus videoroom event: {event.janus}'.format(event=event)) else: self.log.debug('janus videoroom event: {event.janus} (handle_id={event.sender})'.format(event=event)) handler(event) def _EH_janus_videoroom_error(self, event): # fixme: implement error handling self.log.error('got videoroom error event: {}'.format(event.__data__)) try: videoroom_session = self.videoroom_sessions[event.sender] except KeyError: self.log.warning('could not find room session with handle ID {event.sender} for error event'.format(event=event)) return if videoroom_session.type == 'publisher': pass else: pass def _EH_janus_videoroom_webrtcup(self, event): try: videoroom_session = self.videoroom_sessions[event.sender] except KeyError: self.log.warning('could not find room session with handle ID {event.sender} for webrtcup event'.format(event=event)) return if videoroom_session.type == 'publisher': self.log.debug('media published to room {session.room.uri}'.format(session=videoroom_session)) self.send(sylkrtc.VideoroomSessionEstablishedEvent(session=videoroom_session.id)) else: self.send(sylkrtc.VideoroomFeedEstablishedEvent(session=videoroom_session.parent_session.id, feed=videoroom_session.id)) def _EH_janus_videoroom_hangup(self, event): try: videoroom_session = self.videoroom_sessions[event.sender] except KeyError: return reactor.callLater(2, call_in_green_thread, self._cleanup_videoroom_session, videoroom_session) self.log.debug('session with room {session.room.uri} ended'.format(session=videoroom_session)) def _EH_janus_videoroom_slowlink(self, event): try: videoroom_session = self.videoroom_sessions[event.sender] except KeyError: self.log.warning('could not find room session with handle ID {event.sender} for slowlink event'.format(event=event)) return if event.uplink: # uplink is from janus' point of view if not videoroom_session.slow_download: self.log.debug('poor download connectivity to room {session.room.uri} with session {session.id}'.format(session=videoroom_session)) videoroom_session.slow_download = True else: if not videoroom_session.slow_upload: self.log.debug('poor upload connectivity to room {session.room.uri} with session {session.id}'.format(session=videoroom_session)) videoroom_session.slow_upload = True def _EH_janus_videoroom_media(self, event): pass def _EH_janus_videoroom_detached(self, event): pass def _EH_janus_videoroom_joined(self, event): # send when a publisher successfully joined a room try: videoroom_session = self.videoroom_sessions[event.sender] except KeyError: self.log.warning('could not find room session with handle ID {event.sender} for joined event'.format(event=event)) return if ('m=video' in event.jsep.sdp and 'm=audio' in event.jsep.sdp): media = 'audio/video' elif ('m=video' in event.jsep.sdp): media = 'video only' elif ('m=audio' in event.jsep.sdp): media = 'audio only' else: media = 'unknown' self.log.info('joined room {session.room.uri} with {media}'.format(session=videoroom_session, media=media)) self.log.debug('joined room {session.room.uri} with session {session.id}'.format(session=videoroom_session)) data = event.plugindata.data # type: janus.VideoroomJoined videoroom_session.publisher_id = data.id room = videoroom_session.room assert event.jsep is not None self.send(sylkrtc.VideoroomSessionAcceptedEvent(session=videoroom_session.id, sdp=event.jsep.sdp, audio=room.audio, video=room.video)) # send information about existing publishers publishers = [] for publisher in data.publishers: # type: janus.VideoroomPublisher try: publisher_session = room[publisher.id] except KeyError: self.log.warning('could not find matching session for publisher {publisher.id} during joined event'.format(publisher=publisher)) else: publishers.append(dict(id=publisher_session.id, uri=publisher_session.account.id, display_name=publisher.display or '')) self.send(sylkrtc.VideoroomInitialPublishersEvent(session=videoroom_session.id, publishers=publishers)) room.add(videoroom_session) # adding the session to the room might also trigger sending an event with the active participants which must be sent last def _EH_janus_videoroom_attached(self, event): try: videoroom_session = self.videoroom_sessions[event.sender] except KeyError: self.log.warning('could not find room session with handle ID {event.sender} for attached event'.format(event=event)) return # get the session which originated the subscription base_session = videoroom_session.parent_session assert base_session is not None assert event.jsep is not None and event.jsep.type == 'offer' if ('m=video' in event.jsep.sdp and 'm=audio' in event.jsep.sdp): media = 'audio/video' elif ('m=video' in event.jsep.sdp): media = 'video only' elif ('m=audio' in event.jsep.sdp): media = 'audio only' else: media = 'unknown' self.log.debug('{media} media proposed to room {session.room.uri}'.format(session=videoroom_session, media=media)) self.send(sylkrtc.VideoroomFeedAttachedEvent(session=base_session.id, feed=videoroom_session.id, sdp=event.jsep.sdp)) def _EH_janus_videoroom_slow_link(self, event): pass def _EH_janus_videoroom_event_publishers(self, event): try: videoroom_session = self.videoroom_sessions[event.sender] except KeyError: self.log.warning('could not find room session with handle ID {event.sender} for publishers event'.format(event=event)) return room = videoroom_session.room # send information about new publishers publishers = [] for publisher in event.plugindata.data.publishers: # type: janus.VideoroomPublisher try: publisher_session = room[publisher.id] except KeyError: self.log.warning('could not find matching session for publisher {publisher.id} during publishers event'.format(publisher=publisher)) continue publishers.append(dict(id=publisher_session.id, uri=publisher_session.account.id, display_name=publisher.display or '')) self.send(sylkrtc.VideoroomPublishersJoinedEvent(session=videoroom_session.id, publishers=publishers)) def _EH_janus_videoroom_event_leaving(self, event): # this is a publisher publisher_id = event.plugindata.data.leaving # publisher_id == 'ok' when the event is about ourselves leaving the room, else the publisher's janus ID try: base_session = self.videoroom_sessions[event.sender] except KeyError: if publisher_id != 'ok': self.log.warning('could not find room session with handle ID {event.sender} for leaving event'.format(event=event)) return if publisher_id == 'ok': self.log.info('left room {session.room.uri}'.format(session=base_session)) self.log.debug('left room {session.room.uri} with session {session.id}'.format(session=base_session)) self._cleanup_videoroom_session(base_session) return try: publisher_session = base_session.feeds.pop(publisher_id) except KeyError: return self.send(sylkrtc.VideoroomPublishersLeftEvent(session=base_session.id, publishers=[publisher_session.id])) def _EH_janus_videoroom_event_left(self, event): # this is a subscriber try: videoroom_session = self.videoroom_sessions[event.sender] except KeyError: pass else: self._cleanup_videoroom_session(videoroom_session) def _EH_janus_videoroom_event_configured(self, event): pass def _EH_janus_videoroom_event_started(self, event): pass def _EH_janus_videoroom_event_unpublished(self, event): pass # Notification handlers def _NH_ChatSessionGotMessage(self, notification): session = notification.sender.sylk_session # type: VideoroomSessionInfo message = notification.data.message sender = sylkrtc.SIPIdentity(uri=str(message.sender.uri), display_name=message.sender.display_name) - content = message.content if isinstance(message.content, unicode) else message.content.decode('latin1') # preserve binary data for transmitting over JSON + content = message.content if isinstance(message.content, str) else message.content.decode('latin1') # preserve binary data for transmitting over JSON if any(header.name == 'Message-Type' and header.value == 'status' and header.namespace == 'urn:ag-projects:xml:ns:cpim' for header in message.additional_headers): message_type = 'status' else: message_type = 'normal' self.send(sylkrtc.VideoroomMessageEvent(session=session.id, content=content, content_type=message.content_type, sender=sender, timestamp=str(message.timestamp), type=message_type)) def _NH_ChatSessionGotComposingIndication(self, notification): session = notification.sender.sylk_session # type: VideoroomSessionInfo composing = notification.data sender = sylkrtc.SIPIdentity(uri=str(composing.sender.uri), display_name=composing.sender.display_name) self.send(sylkrtc.VideoroomComposingIndicationEvent(session=session.id, state=composing.state, refresh=composing.refresh, content_type=composing.content_type, sender=sender)) def _NH_ChatSessionDidDeliverMessage(self, notification): session = notification.sender.sylk_session # type: VideoroomSessionInfo data = notification.data self.send(sylkrtc.VideoroomMessageDeliveryEvent(session=session.id, delivered=True, message_id=data.message_id, code=data.code, reason=data.reason)) def _NH_ChatSessionDidNotDeliverMessage(self, notification): session = notification.sender.sylk_session # type: VideoroomSessionInfo data = notification.data self.send(sylkrtc.VideoroomMessageDeliveryEvent(session=session.id, delivered=False, message_id=data.message_id, code=data.code, reason=data.reason)) def _NH_SIPMessageDidSucceed(self, notification): self.log.info('message was accepted by remote party') data = notification.data body = CPIMPayload.decode(notification.sender.body) message_id = next((header.value for header in body.additional_headers if header.name == 'Message-ID'), None) account_info = self.accounts_map['%s@%s' % (body.sender.uri.user, body.sender.uri.host)] timestamp = body.timestamp if body.content_type != IMDNDocument.content_type: self.send(sylkrtc.AccountDispositionNotificationEvent(account=account_info.id, state='accepted', message_id=message_id, code=data.code, reason=data.reason, timestamp=timestamp)) def _NH_SIPMessageDidFail(self, notification): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=notification.sender) data = notification.data body = CPIMPayload.decode(notification.sender.body) self.log.warning('could not deliver message to %s: %d %s' % (', '.join(([str(item.uri) for item in body.recipients])), notification.data.code, notification.data.reason)) message_id = next((header.value for header in body.additional_headers if header.name == 'Message-ID'), None) account_info = self.accounts_map['%s@%s' % (body.sender.uri.user, body.sender.uri.host)] timestamp = body.timestamp if body.content_type != IMDNDocument.content_type: self.send(sylkrtc.AccountDispositionNotificationEvent(account=account_info.id, state='failed', message_id=message_id, code=data.code, reason=data.reason, timestamp=timestamp)) # noinspection PyPep8Naming +@implementer(IObserver) class VideoroomChatHandler(object): - implements(IObserver) def __init__(self, session): self.sylk_session = session # type: VideoroomSessionInfo self.sip_session = None # type: Optional[Session] self.chat_stream = None self._started = False self._ended = False self._message_queue = deque() @property def account(self): return self.sylk_session.account @property def room(self): return self.sylk_session.room @run_in_green_thread def start(self): if self._started: return self._started = True notification_center = NotificationCenter() from_uri = SIPURI.parse(self.account.uri) to_uri = SIPURI.parse('sip:{}'.format(self.room.uri)) to_uri.host = to_uri.host.replace('videoconference', 'conference', 1) # TODO: find a way to define this credentials = Credentials(username=from_uri.user, password=self.account.password.encode('utf-8'), digest=True) sip_account = DefaultAccount() sip_settings = SIPSimpleSettings() if sip_account.sip.outbound_proxy is not None: uri = SIPURI(host=sip_account.sip.outbound_proxy.host, port=sip_account.sip.outbound_proxy.port, parameters={'transport': sip_account.sip.outbound_proxy.transport}) else: uri = to_uri lookup = DNSLookup() try: route = lookup.lookup_sip_proxy(uri, sip_settings.sip.transport_list).wait()[0] except (DNSLookupError, IndexError): self.end() self.room.log.error('DNS lookup for SIP proxy for {} failed'.format(uri)) self.room.log.error('chat session for {} failed: DNS lookup error'.format(self.account.id)) notification_center.post_notification('ChatSessionDidFail', sender=self, data=NotificationData(originator='local', code=0, reason=None, failure_reason='DNS lookup error')) return if self._ended: # end was called during DNS lookup self.room.log.debug('chat session for {} ended'.format(self.account.id)) notification_center.post_notification('ChatSessionDidEnd', sender=self) return self.sip_session = Session(sip_account) self.chat_stream = MediaStreamRegistry.ChatStream() notification_center.add_observer(self, sender=self.sip_session) notification_center.add_observer(self, sender=self.chat_stream) self.room.log.debug('chat {} starting at {}'.format(to_uri, route)) self.sip_session.connect(FromHeader(from_uri, self.account.display_name), ToHeader(to_uri), route=route, streams=[self.chat_stream], credentials=credentials) @run_in_twisted_thread def end(self): if self._ended: return notification_center = NotificationCenter() if self.sip_session is not None: notification_center.remove_observer(self, sender=self.sip_session) notification_center.remove_observer(self, sender=self.chat_stream) self.sip_session.end() self.sip_session = None self.chat_stream = None self.room.log.debug('chat session for {} ended'.format(self.account.id)) notification_center.post_notification('ChatSessionDidEnd', sender=self) while self._message_queue: message_id, content, content_type = self._message_queue.popleft() data = NotificationData(message_id=message_id, message=None, code=0, reason='Chat session ended') notification_center.post_notification('ChatSessionDidNotDeliverMessage', sender=self, data=data) self._ended = True @run_in_twisted_thread def send_message(self, message_id, content, content_type='text/plain'): if self._ended: notification_center = NotificationCenter() data = NotificationData(message_id=message_id, message=None, code=0, reason='Chat session ended') notification_center.post_notification('ChatSessionDidNotDeliverMessage', sender=self, data=data) else: self._message_queue.append((message_id, content, content_type)) if self.chat_stream is not None: self._send_queued_messages() @run_in_twisted_thread def send_composing_indication(self, state, refresh=None): if self.chat_stream is not None: self.chat_stream.send_composing_indication(state, refresh=refresh) def _send_queued_messages(self): while self._message_queue: message_id, content, content_type = self._message_queue.popleft() self.chat_stream.send_message(content, content_type, message_id=message_id) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidStart(self, notification): self.room.log.debug('chat session for {} started'.format(self.account.id)) notification.center.post_notification('ChatSessionDidStart', sender=self) self._send_queued_messages() def _NH_SIPSessionDidEnd(self, notification): notification.center.remove_observer(self, sender=self.sip_session) notification.center.remove_observer(self, sender=self.chat_stream) self.sip_session = None self.chat_stream = None self.end() self.room.log.debug('chat session for {} ended'.format(self.account.id)) notification.center.post_notification('ChatSessionDidEnd', sender=self, data=notification.data) def _NH_SIPSessionDidFail(self, notification): notification.center.remove_observer(self, sender=self.sip_session) notification.center.remove_observer(self, sender=self.chat_stream) self.sip_session = None self.chat_stream = None self.end() self.room.log.error('chat session for {} failed: {}'.format(self.account.id, notification.data.failure_reason)) notification.center.post_notification('ChatSessionDidFail', sender=self, data=notification.data) # noinspection PyUnusedLocal def _NH_SIPSessionNewProposal(self, notification): self.sip_session.reject_proposal() def _NH_SIPSessionTransferNewIncoming(self, notification): # sylkserver's SIP Session class doesn't implement the transfer API # self.sip_session.reject_transfer(403) pass def _NH_ChatStreamGotMessage(self, notification): self.chat_stream.msrp_session.send_report(notification.data.chunk, 200, 'OK') notification.center.post_notification('ChatSessionGotMessage', sender=self, data=notification.data) def _NH_ChatStreamGotComposingIndication(self, notification): notification.center.post_notification('ChatSessionGotComposingIndication', sender=self, data=notification.data) def _NH_ChatStreamDidSendMessage(self, notification): notification.center.post_notification('ChatSessionDidSendMessage', sender=self, data=notification.data) def _NH_ChatStreamDidDeliverMessage(self, notification): notification.center.post_notification('ChatSessionDidDeliverMessage', sender=self, data=notification.data) def _NH_ChatStreamDidNotDeliverMessage(self, notification): notification.center.post_notification('ChatSessionDidNotDeliverMessage', sender=self, data=notification.data) diff --git a/sylk/applications/webrtcgateway/janus.py b/sylk/applications/webrtcgateway/janus.py index 6233885..8f293b4 100644 --- a/sylk/applications/webrtcgateway/janus.py +++ b/sylk/applications/webrtcgateway/janus.py @@ -1,360 +1,358 @@ import json from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.types import Singleton from autobahn.twisted.websocket import connectWS, WebSocketClientFactory, WebSocketClientProtocol from eventlib.twistedutil import block_on from twisted.internet import reactor, defer from twisted.internet.protocol import ReconnectingClientFactory from twisted.python.failure import Failure -from zope.interface import implements +from zope.interface import implementer from sylk import __version__ from .configuration import JanusConfig from .logger import log from .models import janus class JanusError(Exception): def __init__(self, code, reason): super(JanusError, self).__init__(reason) self.code = code self.reason = reason class JanusClientProtocol(WebSocketClientProtocol): _event_handlers = None _pending_transactions = None _keepalive_timers = None _keepalive_interval = 45 notification_center = NotificationCenter() def onOpen(self): self.notification_center.post_notification('JanusBackendConnected', sender=self) self._pending_transactions = {} self._keepalive_timers = {} self._event_handlers = {} def onMessage(self, payload, isBinary): if isBinary: log.warn('Unexpected binary payload received') return self.notification_center.post_notification('WebRTCJanusTrace', sender=self, data=NotificationData(direction='INCOMING', message=payload, peer=self.peer)) try: message = janus.JanusMessage.from_payload(json.loads(payload)) except Exception as e: log.warning('Error decoding Janus message: {!s}'.format(e)) return if isinstance(message, (janus.CoreEvent, janus.PluginEvent)): # some of the plugin events might have the transaction, but we do not finalize # the transaction for them as they are not direct responses for the transaction handler = self._event_handlers.get(message.sender, Null) try: handler(message) except Exception as e: log.exception('Error while running Janus event handler: {!s}'.format(e)) return # at this point it can only be a response. clear the transaction and return the answer. try: request, deferred = self._pending_transactions.pop(message.transaction) except KeyError: log.warn('Discarding unexpected response: %s' % payload) return if isinstance(message, janus.AckResponse): deferred.callback(None) elif isinstance(message, janus.SuccessResponse): deferred.callback(message) elif isinstance(message, janus.ErrorResponse): deferred.errback(JanusError(message.error.code, message.error.reason)) else: assert isinstance(message, janus.PluginResponse) plugin_data = message.plugindata.data if isinstance(plugin_data, (janus.SIPErrorEvent, janus.VideoroomErrorEvent)): deferred.errback(JanusError(plugin_data.error_code, plugin_data.error)) else: deferred.callback(message) def connectionLost(self, reason): super(JanusClientProtocol, self).connectionLost(reason) self.notification_center.post_notification('JanusBackendDisconnected', sender=self, data=NotificationData(reason=reason.getErrorMessage())) - def disconnect(self, code=1000, reason=u''): + def disconnect(self, code=1000, reason=''): self.sendClose(code, reason) def _send_request(self, request): if request.janus != 'keepalive' and 'session_id' in request: # postpone keepalive messages as long as we have non-keepalive traffic for a given session keepalive_timer = self._keepalive_timers.get(request.session_id, None) if keepalive_timer is not None and keepalive_timer.active(): keepalive_timer.reset(self._keepalive_interval) deferred = defer.Deferred() message = json.dumps(request.__data__) self.notification_center.post_notification('WebRTCJanusTrace', sender=self, data=NotificationData(direction='OUTGOING', message=message, peer=self.peer)) self.sendMessage(message) self._pending_transactions[request.transaction] = request, deferred return deferred def _start_keepalive(self, response): session_id = response.data.id self._keepalive_timers[session_id] = reactor.callLater(self._keepalive_interval, self._send_keepalive, session_id) return response def _stop_keepalive(self, session_id): timer = self._keepalive_timers.pop(session_id, None) if timer is not None and timer.active(): timer.cancel() def _send_keepalive(self, session_id): deferred = self._send_request(janus.SessionKeepaliveRequest(session_id=session_id)) deferred.addBoth(self._keepalive_callback, session_id) def _keepalive_callback(self, result, session_id): if isinstance(result, Failure): self._keepalive_timers.pop(session_id) else: self._keepalive_timers[session_id] = reactor.callLater(self._keepalive_interval, self._send_keepalive, session_id) # Public API def set_event_handler(self, handle_id, event_handler): if event_handler is None: self._event_handlers.pop(handle_id, None) - log.debug("Destroy Janus session, %d handlers in use" % len(self._event_handlers.keys())); + log.debug("Destroy Janus session, %d handlers in use" % len(list(self._event_handlers.keys()))); else: assert callable(event_handler) self._event_handlers[handle_id] = event_handler - log.debug("Create Janus session, %d handlers in use" % len(self._event_handlers.keys())); + log.debug("Create Janus session, %d handlers in use" % len(list(self._event_handlers.keys()))); def info(self): return self._send_request(janus.InfoRequest()) def create_session(self): return self._send_request(janus.SessionCreateRequest()).addCallback(self._start_keepalive) def destroy_session(self, session_id): self._stop_keepalive(session_id) return self._send_request(janus.SessionDestroyRequest(session_id=session_id)) def attach_plugin(self, session_id, plugin): return self._send_request(janus.PluginAttachRequest(session_id=session_id, plugin=plugin)) def detach_plugin(self, session_id, handle_id): return self._send_request(janus.PluginDetachRequest(session_id=session_id, handle_id=handle_id)) def message(self, session_id, handle_id, body, jsep=None): if jsep is not None: return self._send_request(janus.MessageRequest(session_id=session_id, handle_id=handle_id, body=body, jsep=jsep)) else: return self._send_request(janus.MessageRequest(session_id=session_id, handle_id=handle_id, body=body)) def trickle(self, session_id, handle_id, candidates): return self._send_request(janus.TrickleRequest(session_id=session_id, handle_id=handle_id, candidates=candidates)) class JanusClientFactory(ReconnectingClientFactory, WebSocketClientFactory): noisy = False protocol = JanusClientProtocol -class JanusBackend(object): - __metaclass__ = Singleton - - implements(IObserver) +@implementer(IObserver) +class JanusBackend(object, metaclass=Singleton): def __init__(self): self.factory = JanusClientFactory(url=JanusConfig.api_url, protocols=['janus-protocol'], useragent='SylkServer/%s' % __version__) self.connector = None self.protocol = Null self._stopped = False @property def ready(self): return self.protocol is not Null def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, name='JanusBackendConnected') notification_center.add_observer(self, name='JanusBackendDisconnected') self.connector = connectWS(self.factory) def stop(self): if self._stopped: return self._stopped = True self.factory.stopTrying() notification_center = NotificationCenter() notification_center.discard_observer(self, name='JanusBackendConnected') notification_center.discard_observer(self, name='JanusBackendDisconnected') if self.connector is not None: self.connector.disconnect() self.connector = None if self.protocol is not None: self.protocol.disconnect() self.protocol = Null def set_event_handler(self, handle_id, event_handler): self.protocol.set_event_handler(handle_id, event_handler) def info(self): return self.protocol.info() def create_session(self): return self.protocol.create_session() def destroy_session(self, session_id): return self.protocol.destroy_session(session_id) def attach_plugin(self, session_id, plugin): return self.protocol.attach_plugin(session_id, plugin) def detach_plugin(self, session_id, handle_id): return self.protocol.detach_plugin(session_id, handle_id) def message(self, session_id, handle_id, body, jsep=None): return self.protocol.message(session_id, handle_id, body, jsep) def trickle(self, session_id, handle_id, candidates): return self.protocol.trickle(session_id, handle_id, candidates) # Notification handling def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_JanusBackendConnected(self, notification): assert self.protocol is Null self.protocol = notification.sender log.info('Janus backend connection up') self.factory.resetDelay() def _NH_JanusBackendDisconnected(self, notification): log.info('Janus backend connection down: %s' % notification.data.reason) self.protocol = Null class JanusSession(object): backend = JanusBackend() def __init__(self): response = block_on(self.backend.create_session()) # type: janus.SuccessResponse self.id = response.data.id def destroy(self): return self.backend.destroy_session(self.id) class JanusPluginHandle(object): backend = JanusBackend() plugin = None def __init__(self, session, event_handler): if self.plugin is None: raise TypeError('Cannot instantiate {0.__class__.__name__} with no associated plugin'.format(self)) response = block_on(self.backend.attach_plugin(session.id, self.plugin)) # type: janus.SuccessResponse self.id = response.data.id self.session = session self.backend.set_event_handler(self.id, event_handler) def __enter__(self): return self def __exit__(self, exception_type, exception_value, traceback): self.detach() def detach(self): try: block_on(self.backend.detach_plugin(self.session.id, self.id)) except JanusError as e: log.warning('could not detach Janus plugin: %s', e) self.backend.set_event_handler(self.id, None) - def message(self, body, jsep=None, async=False): + def message(self, body, jsep=None, _async=False): deferred = self.backend.message(self.session.id, self.id, body, jsep) - return deferred if async else block_on(deferred) + return deferred if _async else block_on(deferred) - def trickle(self, candidates, async=False): + def trickle(self, candidates, _async=False): deferred = self.backend.trickle(self.session.id, self.id, candidates) - return deferred if async else block_on(deferred) + return deferred if _async else block_on(deferred) class GenericPluginHandle(JanusPluginHandle): def __init__(self, plugin, session, event_handler): self.plugin = plugin super(GenericPluginHandle, self).__init__(session, event_handler) class SIPPluginHandle(JanusPluginHandle): plugin = 'janus.plugin.sip' def register(self, account, proxy=None): send_register = True if account.auth_handle.type == 'SIP' else False self.message(janus.SIPRegister(proxy=proxy, send_register=send_register, **account.user_data)) def unregister(self): self.message(janus.SIPUnregister()) def call(self, account, uri, sdp, proxy=None): # in order to make a call we need to register first. do so without actually registering, as we are already registered self.message(janus.SIPRegister(proxy=proxy, send_register=False, **account.user_data)) self.message(janus.SIPCall(uri=uri, srtp='sdes_optional'), jsep=janus.SDPOffer(sdp=sdp)) def accept(self, sdp): self.message(janus.SIPAccept(), jsep=janus.SDPAnswer(sdp=sdp)) def decline(self, code=486): self.message(janus.SIPDecline(code=code)) def hangup(self): self.message(janus.SIPHangup()) class VideoroomPluginHandle(JanusPluginHandle): plugin = 'janus.plugin.videoroom' def create(self, room, config, publishers=10): self.message(janus.VideoroomCreate(room=room, publishers=publishers, **config.janus_data)) def destroy(self, room): try: self.message(janus.VideoroomDestroy(room=room)) except JanusError as e: log.warning('could not destroy video room %s: %s', room, e) def join(self, room, sdp, audio, video, display_name=None): if display_name: self.message(janus.VideoroomJoin(room=room, audio=audio, video=video, display=display_name), jsep=janus.SDPOffer(sdp=sdp)) else: self.message(janus.VideoroomJoin(room=room, audio=audio, video=video), jsep=janus.SDPOffer(sdp=sdp)) def leave(self): self.message(janus.VideoroomLeave()) def update_publisher(self, options): self.message(janus.VideoroomUpdatePublisher(**options)) def feed_attach(self, room, feed, offer_audio, offer_video): self.message(janus.VideoroomFeedAttach(room=room, feed=feed, offer_audio=offer_audio, offer_video=offer_video)) def feed_detach(self): self.message(janus.VideoroomFeedDetach()) def feed_start(self, sdp): self.message(janus.VideoroomFeedStart(), jsep=janus.SDPAnswer(sdp=sdp)) def feed_pause(self): self.message(janus.VideoroomFeedPause()) def feed_resume(self): self.message(janus.VideoroomFeedStart()) def feed_update(self, options): self.message(janus.VideoroomFeedUpdate(**options)) diff --git a/sylk/applications/webrtcgateway/models/jsonobjects.py b/sylk/applications/webrtcgateway/models/jsonobjects.py index 334d7bb..2f28b28 100644 --- a/sylk/applications/webrtcgateway/models/jsonobjects.py +++ b/sylk/applications/webrtcgateway/models/jsonobjects.py @@ -1,537 +1,532 @@ class Validator(object): def validate(self, value): """Check value and raise ValueError if invalid, else return the (possibly modified) value""" return value class CompositeValidator(Validator): def __init__(self, *validators): if len(validators) < 2: raise TypeError('need at least two validators to create a CompositeValidator') if not all(isinstance(validator, Validator) for validator in validators): raise ValueError('validators need to be Validator instances') self.validators = validators def validate(self, value): for validator in self.validators: value = validator.validate(value) return value class MultiType(tuple): """ A collection of types for which isinstance(obj, multi_type) returns True if 'obj' is an instance of any of the types in the multi_type. Instantiating the multi_type will instantiate the first type in the multi_type. """ # noinspection PyArgumentList def __new__(cls, *args): if not args: raise ValueError('{.__name__} must have at least one type'.format(cls)) instance = super(MultiType, cls).__new__(cls, args) instance.__name__ = ', '.join(cls.__name__ for cls in args) instance.main_type = args[0] return instance def __call__(self, value): return self.main_type(value) class AbstractProperty(object): data_type = object container = False def __init__(self, optional=False, default=None, validator=None): if validator is not None and not isinstance(validator, Validator): raise TypeError('validator should be a Validator instance or None') self.default = default self.optional = optional self.validator = validator self.name = None # will be set by the JSONObjectType metaclass when associating properties with objects def __get__(self, instance, owner): if instance is None: return self return instance.__dict__.get(self.name, self.default) # mandatory properties are guaranteed to be present, only optional ones can be missing def __set__(self, instance, value): if value is None and self.optional: instance.__dict__[self.name] = None else: instance.__dict__[self.name] = self._parse(value) def __delete__(self, instance): if not self.optional: raise AttributeError('Cannot delete mandatory property {property.name!r} of object {instance.__class__.__name__!r}'.format(instance=instance, property=self)) try: del instance.__dict__[self.name] except KeyError: raise AttributeError(self.name) def _parse(self, value): if not isinstance(value, self.data_type): raise ValueError('Invalid value for {property.name!r} property: {value!r}'.format(property=self, value=value)) if self.validator is not None: value = self.validator.validate(value) return value class BooleanProperty(AbstractProperty): data_type = bool class IntegerProperty(AbstractProperty): - data_type = int, long + data_type = int, int class NumberProperty(AbstractProperty): - data_type = int, long, float + data_type = int, int, float class StringProperty(AbstractProperty): - data_type = str, unicode + data_type = str, str class FixedValueProperty(AbstractProperty): def __init__(self, value): super(FixedValueProperty, self).__init__(optional=True, default=value) self.value = value def _parse(self, value): if value != self.value: raise ValueError('Invalid value for {property.name!r} property: {value!r} (should be {property.value!r})'.format(property=self, value=value)) return value class LimitedChoiceProperty(AbstractProperty): def __init__(self, values, optional=False, default=None): if not values: raise ValueError('values needs to be an non-empty sequence of elements') if optional and default is not None and default not in values: raise ValueError('default value needs to be one of the allowed values or None') super(LimitedChoiceProperty, self).__init__(optional=optional, default=default) self.values = frozenset(values) self.values_string = ' or '.join(', '.join(sorted(values)).rsplit(', ', 1)) def _parse(self, value): if value not in self.values: raise ValueError('Invalid value for {property.name!r} property: {value!r} (expected: {property.values_string})'.format(property=self, value=value)) return value class ArrayProperty(AbstractProperty): data_type = list, tuple container = True def __init__(self, array_type, optional=False): if not issubclass(array_type, JSONArray): raise TypeError('array_type should be a subclass of JSONArray') super(ArrayProperty, self).__init__(optional=optional, default=None, validator=None) self.array_type = array_type def _parse(self, value): if type(value) is self.array_type: return value elif isinstance(value, self.data_type): return self.array_type(value) else: raise ValueError('Invalid value for {property.name!r} property: {value!r}'.format(property=self, value=value)) class ObjectProperty(AbstractProperty): data_type = dict container = True def __init__(self, object_type, optional=False): if not issubclass(object_type, JSONObject): raise TypeError('object_type should be a subclass of JSONObject') super(ObjectProperty, self).__init__(optional=optional, default=None, validator=None) self.object_type = object_type def _parse(self, value): if type(value) is self.object_type: return value elif isinstance(value, self.data_type): return self.object_type(**value) else: raise ValueError('Invalid value for {property.name!r} property: {value!r}'.format(property=self, value=value)) class PropertyContainer(object): def __init__(self, cls): - self.__dict__.update({item.name: item for cls in reversed(cls.__mro__) for item in cls.__dict__.itervalues() if isinstance(item, AbstractProperty)}) + self.__dict__.update({item.name: item for cls in reversed(cls.__mro__) for item in cls.__dict__.values() if isinstance(item, AbstractProperty)}) def __getitem__(self, name): return self.__dict__[name] def __contains__(self, name): return name in self.__dict__ def __iter__(self): - return self.__dict__.itervalues() + return iter(self.__dict__.values()) @property def names(self): return set(self.__dict__) class JSONObjectType(type): # noinspection PyShadowingBuiltins def __init__(cls, name, bases, dictionary): super(JSONObjectType, cls).__init__(name, bases, dictionary) - for name, property in ((name, item) for name, item in dictionary.iteritems() if isinstance(item, AbstractProperty)): + for name, property in ((name, item) for name, item in dictionary.items() if isinstance(item, AbstractProperty)): property.name = name cls.__properties__ = PropertyContainer(cls) -class JSONObject(object): - __metaclass__ = JSONObjectType - - # noinspection PyShadowingBuiltins +class JSONObject(object, metaclass=JSONObjectType): def __init__(self, **data): for property in self.__properties__: if property.name in data: property.__set__(self, data[property.name]) elif not property.optional: raise ValueError('Mandatory property {property.name!r} of {object.__class__.__name__!r} object is missing'.format(property=property, object=self)) # noinspection PyShadowingBuiltins @property def __data__(self): data = {} for property in self.__properties__: value = self.__dict__.get(property.name, property.default) if value is not None: data[property.name] = value.__data__ if property.container else value elif property.name in self.__dict__: data[property.name] = None return data def __contains__(self, name): return name in self.__properties__ class ArrayParser(object): def __init__(self, cls): self.item_type = MultiType(*cls.item_type) if isinstance(cls.item_type, (list, tuple)) else cls.item_type self.item_validator = cls.item_validator # this is only used for primitive item types if isinstance(self.item_type, JSONObjectType): self.parse_item = self.__parse_object_item self.parse_list = self.__parse_object_list elif isinstance(self.item_type, JSONArrayType): self.parse_item = self.__parse_array_item self.parse_list = self.__parse_array_list else: self.parse_item = self.__parse_primitive_item self.parse_list = self.__parse_primitive_list def __parse_primitive_item(self, item): if not isinstance(item, self.item_type): raise ValueError('Invalid value for {type.__name__}: {item!r}'.format(type=self.item_type, item=item)) if self.item_validator is not None: item = self.item_validator.validate(item) return item def __parse_primitive_list(self, iterable): item_type = self.item_type for item in iterable: if not isinstance(item, item_type): raise ValueError('Invalid value for {type.__name__}: {item!r}'.format(type=item_type, item=item)) if self.item_validator is not None: # note: can be optimized by moving this test outside the loop (not sure if the decreased readability is worth it) item = self.item_validator.validate(item) yield item def __parse_array_item(self, item): try: return item if type(item) is self.item_type else self.item_type(item) except TypeError: raise ValueError('Invalid value for {type.__name__}: {item!r}'.format(type=self.item_type, item=item)) def __parse_array_list(self, iterable): item_type = self.item_type for item in iterable: try: yield item if type(item) is item_type else item_type(item) except TypeError: raise ValueError('Invalid value for {type.__name__}: {item!r}'.format(type=item_type, item=item)) def __parse_object_item(self, item): try: return item if type(item) is self.item_type else self.item_type(**item) except TypeError: raise ValueError('Invalid value for {type.__name__}: {item!r}'.format(type=self.item_type, item=item)) def __parse_object_list(self, iterable): item_type = self.item_type for item in iterable: try: yield item if type(item) is item_type else item_type(**item) except TypeError: raise ValueError('Invalid value for {type.__name__}: {item!r}'.format(type=item_type, item=item)) class JSONArrayType(type): item_type = object item_validator = None list_validator = None def __init__(cls, name, bases, dictionary): super(JSONArrayType, cls).__init__(name, bases, dictionary) if cls.item_validator is not None and isinstance(cls.item_type, (JSONArrayType, JSONObjectType)): raise TypeError('item_validator is not used for JSONArray and JSONObject item types as they have their own validators') if cls.item_validator is not None and not isinstance(cls.item_validator, Validator): raise TypeError('item_validator should be a Validator instance or None') if cls.list_validator is not None and not isinstance(cls.list_validator, Validator): raise TypeError('list_validator should be a Validator instance or None') cls.parser = ArrayParser(cls) -class JSONArray(object): - __metaclass__ = JSONArrayType - +class JSONArray(object, metaclass=JSONArrayType): item_type = object item_validator = None # this should only be defined for primitive item types list_validator = None def __init__(self, iterable): - if isinstance(iterable, basestring): # prevent iterable primitive types from being interpreted as arrays + if isinstance(iterable, str): # prevent iterable primitive types from being interpreted as arrays raise ValueError('Invalid value for {.__class__.__name__}: {!r}'.format(self, iterable)) items = list(self.parser.parse_list(iterable)) if self.list_validator is not None: items = self.list_validator.validate(items) self.__items__ = items @property def __data__(self): return [item.__data__ for item in self.__items__] if isinstance(self.item_type, (JSONArrayType, JSONObjectType)) else self.__items__[:] def __repr__(self): return '{0.__class__.__name__}({0.__items__!r})'.format(self) def __contains__(self, item): return item in self.__items__ def __iter__(self): return iter(self.__items__) def __len__(self): return len(self.__items__) def __reversed__(self): return reversed(self.__items__) __hash__ = None def __getitem__(self, index): return self.__items__[index] def __setitem__(self, index, value): value = self.parser.parse_item(value) if self.list_validator is not None: clone = self.__items__[:] clone[index] = value self.__items__ = self.list_validator.validate(clone) else: self.__items__[index] = value def __delitem__(self, index): if self.list_validator is not None: clone = self.__items__[:] del clone[index] self.__items__ = self.list_validator.validate(clone) else: del self.__items__[index] def __getslice__(self, i, j): return self.__items__[i:j] def __setslice__(self, i, j, sequence): sequence = list(self.parser.parse_list(sequence)) if self.list_validator is not None: clone = self.__items__[:] clone[i:j] = sequence self.__items__ = self.list_validator.validate(clone) else: self.__items__[i:j] = sequence def __delslice__(self, i, j): if self.list_validator is not None: clone = self.__items__[:] del clone[i:j] self.__items__ = self.list_validator.validate(clone) else: del self.__items__[i:j] def __add__(self, other): if isinstance(other, JSONArray): return self.__class__(self.__items__ + other.__items__) else: return self.__class__(self.__items__ + other) def __radd__(self, other): if isinstance(other, JSONArray): return self.__class__(other.__items__ + self.__items__) else: return self.__class__(other + self.__items__) def __iadd__(self, other): if isinstance(other, JSONArray) and self.item_type == other.item_type: items = other.__items__ else: items = list(self.parser.parse_list(other)) if self.list_validator is not None: clone = self.__items__[:] clone += items self.__items__ = self.list_validator.validate(clone) else: self.__items__ += items return self def __mul__(self, n): return self.__class__(self.__items__ * n) def __rmul__(self, n): return self.__class__(self.__items__ * n) def __imul__(self, n): if self.list_validator is not None: self.__items__ = self.list_validator.validate(n * self.__items__) else: self.__items__ *= n return self def __eq__(self, other): return self.__items__ == other.__items__ if isinstance(other, JSONArray) else self.__items__ == other def __ne__(self, other): return self.__items__ != other.__items__ if isinstance(other, JSONArray) else self.__items__ != other def __lt__(self, other): return self.__items__ < other.__items__ if isinstance(other, JSONArray) else self.__items__ < other def __le__(self, other): return self.__items__ <= other.__items__ if isinstance(other, JSONArray) else self.__items__ <= other def __gt__(self, other): return self.__items__ > other.__items__ if isinstance(other, JSONArray) else self.__items__ > other def __ge__(self, other): return self.__items__ >= other.__items__ if isinstance(other, JSONArray) else self.__items__ >= other def __format__(self, format_spec): return self.__items__.__format__(format_spec) def index(self, value, *args): return self.__items__.index(value, *args) def count(self, value): return self.__items__.count(value) def append(self, value): value = self.parser.parse_item(value) if self.list_validator is not None: clone = self.__items__[:] clone.append(value) self.__items__ = self.list_validator.validate(clone) else: self.__items__.append(value) def insert(self, index, value): value = self.parser.parse_item(value) if self.list_validator is not None: clone = self.__items__[:] clone.insert(index, value) self.__items__ = self.list_validator.validate(clone) else: self.__items__.insert(index, value) def extend(self, other): if isinstance(other, JSONArray) and self.item_type == other.item_type: items = other.__items__ else: items = list(self.parser.parse_list(other)) if self.list_validator is not None: clone = self.__items__[:] clone.extend(items) self.__items__ = self.list_validator.validate(clone) else: self.__items__.extend(items) def pop(self, index=-1): if self.list_validator is not None: clone = self.__items__[:] clone.pop(index) self.__items__ = self.list_validator.validate(clone) else: self.__items__.pop(index) def remove(self, value): if self.list_validator is not None: clone = self.__items__[:] clone.remove(value) self.__items__ = self.list_validator.validate(clone) else: self.__items__.remove(value) def reverse(self): if self.list_validator is not None: clone = self.__items__[:] clone.reverse() self.__items__ = self.list_validator.validate(clone) else: self.__items__.reverse() def sort(self, key=None, reverse=False): if self.list_validator is not None: clone = self.__items__[:] clone.sort(key=key, reverse=reverse) self.__items__ = self.list_validator.validate(clone) else: self.__items__.sort(key=key, reverse=reverse) class BooleanArray(JSONArray): item_type = bool class IntegerArray(JSONArray): - item_type = int, long + item_type = int, int class NumberArray(JSONArray): - item_type = int, long, float + item_type = int, int, float class StringArray(JSONArray): - item_type = str, unicode + item_type = str, str class ArrayOf(object): def __new__(cls, item_type, name='GenericArray', item_validator=None, list_validator=None): return JSONArrayType(name, (JSONArray,), dict(item_type=item_type, item_validator=item_validator, list_validator=list_validator)) JSONList = JSONArray BooleanList = BooleanArray IntegerList = IntegerArray NumberList = NumberArray StringList = StringArray ListOf = ArrayOf # Abstract container properties class AbstractObjectProperty(AbstractProperty): data_type = JSONObject container = True class AbstractArrayProperty(AbstractProperty): data_type = JSONArray container = True diff --git a/sylk/applications/webrtcgateway/protocol.py b/sylk/applications/webrtcgateway/protocol.py index 7850c5b..6e71ccf 100644 --- a/sylk/applications/webrtcgateway/protocol.py +++ b/sylk/applications/webrtcgateway/protocol.py @@ -1,64 +1,64 @@ import json from application.notification import NotificationCenter, NotificationData from autobahn.twisted.websocket import WebSocketServerProtocol from autobahn.websocket import ConnectionDeny from .handler import ConnectionHandler from .janus import JanusBackend from .logger import log SYLK_WS_PROTOCOL = 'sylkRTC-2' class SylkWebSocketServerProtocol(WebSocketServerProtocol): janus_backend = JanusBackend() connection_handler = None notification_center = NotificationCenter() def onConnect(self, request): if SYLK_WS_PROTOCOL not in request.protocols: log.debug('Connection from {} request: {}'.format(self.peer, request)) log.info('Rejecting connection from {}, client uses unsupported protocol: {}'.format(self.peer, ','.join(request.protocols))) - raise ConnectionDeny(406, u'No compatible protocol specified') + raise ConnectionDeny(406, 'No compatible protocol specified') if not self.janus_backend.ready: log.warning('Rejecting connection from {}, Janus backend is not connected'.format(self.peer)) - raise ConnectionDeny(503, u'Backend is not connected') + raise ConnectionDeny(503, 'Backend is not connected') return SYLK_WS_PROTOCOL def onOpen(self): self.factory.connections.add(self) self.connection_handler = ConnectionHandler(self) self.connection_handler.start() self.connection_handler.log.info('websocket connected from: {address}'.format(address=self.peer)) def onMessage(self, payload, is_binary): if is_binary: self.connection_handler.log.error('received invalid binary message') return self.notification_center.post_notification('WebRTCClientTrace', sender=self, data=NotificationData(direction='INCOMING', message=payload, peer=self.peer)) try: data = json.loads(payload) except Exception as e: self.connection_handler.log.error('could not parse WebSocket payload: {exception!s}'.format(exception=e)) else: self.connection_handler.handle_message(data) def onClose(self, clean, code, reason): if self.connection_handler is None: # Connection was closed very early before onOpen was even called return self.connection_handler.log.info('websocket disconnected from {address}'.format(address=self.peer)) self.factory.connections.discard(self) self.connection_handler.stop() self.connection_handler = None def sendMessage(self, payload, *args, **kw): self.notification_center.post_notification('WebRTCClientTrace', sender=self, data=NotificationData(direction='OUTGOING', message=payload, peer=self.peer)) #log.info('Sending %s to web socket %s' % (payload, self.peer)); super(SylkWebSocketServerProtocol, self).sendMessage(payload, *args, **kw) - def disconnect(self, code=1000, reason=u''): + def disconnect(self, code=1000, reason=''): self.sendClose(code, reason) diff --git a/sylk/applications/webrtcgateway/push.py b/sylk/applications/webrtcgateway/push.py index 8556d79..e471d0c 100644 --- a/sylk/applications/webrtcgateway/push.py +++ b/sylk/applications/webrtcgateway/push.py @@ -1,122 +1,122 @@ import json from twisted.internet import defer, reactor from twisted.web.client import Agent, readBody from twisted.web.iweb import IBodyProducer from twisted.web.http_headers import Headers from zope.interface import implementer from .configuration import GeneralConfig from .logger import log from .models import sylkpush from .storage import TokenStorage __all__ = 'conference_invite' agent = Agent(reactor) headers = Headers({'User-Agent': ['SylkServer'], 'Content-Type': ['application/json']}) @implementer(IBodyProducer) class StringProducer(object): def __init__(self, data): self.body = data self.length = len(data) def startProducing(self, consumer): consumer.write(self.body) return defer.succeed(None) def pauseProducing(self): pass def stopProducing(self): pass def _construct_and_send(result, request, destination): - for device_token, push_parameters in result.iteritems(): + for device_token, push_parameters in result.items(): request.token = device_token request.app_id = push_parameters['app'] request.platform = push_parameters['platform'] request.device_id = push_parameters['device_id'] _send_push_notification(request, destination, device_token) def conference_invite(originator, destination, room, call_id, audio, video): tokens = TokenStorage() if video: media_type = 'video' else: media_type = 'audio' request = sylkpush.ConferenceInviteEvent(token='dummy', app_id='dummy', platform='dummy', device_id='dummy', originator=originator.uri, from_display_name=originator.display_name, to=room, call_id=str(call_id), media_type=media_type) user_tokens = tokens[destination] if isinstance(user_tokens, set): return else: if isinstance(user_tokens, defer.Deferred): user_tokens.addCallback(lambda result: _construct_and_send(result, request, destination)) else: _construct_and_send(user_tokens, request, destination) @defer.inlineCallbacks def _send_push_notification(payload, destination, token): if GeneralConfig.sylk_push_url: try: r = yield agent.request('POST', GeneralConfig.sylk_push_url, headers, StringProducer(json.dumps(payload.__data__))) except Exception as e: log.info('Error sending push notification to %s: %s', GeneralConfig.sylk_push_url, e) else: try: raw_body = yield readBody(r) body = json.loads(raw_body) except Exception as e: log.warning('Error reading response body: %s', e) body = {} try: platform = body['data']['platform'] except KeyError: platform = 'Unknown platform' if r.code != 200: try: reason = body['data']['reason'] except KeyError: reason = None try: details = body['data']['body']['_content']['error']['message'] except KeyError: details = None if reason and details: error_description = "%s %s" % (reason, details) elif reason: error_description = reason else: error_description = body if r.code == 410: if body and 'application/json' in r.headers.getRawHeaders('content-type'): try: token = body['data']['token'] except KeyError: pass else: log.info('Purging expired push token %s/%s' % (destination, token)) tokens = TokenStorage() tokens.remove(destination, token) else: log.warning('Error sending %s push notification for videoroom to %s/%s: %s (%s) %s %s' % (platform.title(), payload.to, destination, token[:15], r.phrase, r.code, error_description)) else: log.info('Sent %s push notify for videoroom %s to %s/%s' % (platform.title(), payload.to, destination, token[:15])) else: log.warning('Cannot send push notification: no Sylk push server configured') diff --git a/sylk/applications/webrtcgateway/storage.py b/sylk/applications/webrtcgateway/storage.py index 839d18c..70809bb 100644 --- a/sylk/applications/webrtcgateway/storage.py +++ b/sylk/applications/webrtcgateway/storage.py @@ -1,172 +1,170 @@ -import cPickle as pickle +import pickle as pickle import os from application.python.types import Singleton from collections import defaultdict from sipsimple.threading import run_in_thread from twisted.internet import defer from sylk.configuration import ServerConfig from .configuration import CassandraConfig __all__ = 'TokenStorage', # TODO: Maybe add some more metadata like the modification date so we know when a token was refreshed, # and thus it's ok to scrap it after a reasonable amount of time. CASSANDRA_MODULES_AVAILABLE = False try: from cassandra.cqlengine import columns, connection except ImportError: pass else: try: from cassandra.cqlengine.models import Model except ImportError: pass else: CASSANDRA_MODULES_AVAILABLE = True from cassandra.cqlengine.query import LWTException class PushTokens(Model): username = columns.Text(partition_key=True) domain = columns.Text(partition_key=True) device_id = columns.Text() app = columns.Text() background_token = columns.Text(required=False) device_token = columns.Text(primary_key=True) platform = columns.Text() silent = columns.Text() user_agent = columns.Text(required=False) class FileStorage(object): def __init__(self): self._tokens = defaultdict() @run_in_thread('file-io') def _save(self): with open(os.path.join(ServerConfig.spool_dir, 'webrtc_device_tokens'), 'wb+') as f: pickle.dump(self._tokens, f) @run_in_thread('file-io') def load(self): try: tokens = pickle.load(open(os.path.join(ServerConfig.spool_dir, 'webrtc_device_tokens'), 'rb')) except Exception: pass else: self._tokens.update(tokens) def __getitem__(self, key): try: return self._tokens[key] except KeyError: return {} def add(self, account, contact_params, user_agent): try: (token, background_token) = contact_params['pn_tok'].split('#') except ValueError: token = contact_params['pn_tok'] background_token = None data = { 'device_id': contact_params['pn_device'], 'platform': contact_params['pn_type'], 'silent': contact_params['pn_silent'], 'app': contact_params['pn_app'], 'user_agent': user_agent, 'background_token': background_token } if account in self._tokens: if isinstance(self._tokens[account], set): self._tokens[account] = {} # Remove old storage layout based on device id if contact_params['pn_device'] in self._tokens[account]: del self._tokens[account][contact_params['pn_device']] # Remove old unsplit token if exists, can be removed if all tokens are stored split if background_token is not None: try: del self._tokens[account][contact_params['pn_tok']] except IndexError: pass self._tokens[account][token] = data else: self._tokens[account] = {token: data} self._save() def remove(self, account, device_token): try: device_token = device_token.split('#')[0] except IndexError: pass try: del self._tokens[account][device_token] except KeyError: pass self._save() class CassandraStorage(object): @run_in_thread('cassandra') def load(self): connection.setup(CassandraConfig.cluster_contact_points, CassandraConfig.keyspace, protocol_version=4) def __getitem__(self, key): deferred = defer.Deferred() @run_in_thread('cassandra') def query_tokens(key): username, domain = key.split('@', 1) tokens = {} for device in PushTokens.objects(PushTokens.username == username, PushTokens.domain == domain): tokens[device.device_token] = {'device_id': device.device_id, 'platform': device.platform, 'silent': device.silent, 'app': device.app} deferred.callback(tokens) return tokens query_tokens(key) return deferred @run_in_thread('cassandra') def add(self, account, contact_params, user_agent): username, domain = account.split('@', 1) try: (token, background_token) = contact_params['pn_tok'].split('#') except ValueError: token = contact_params['pn_tok'] background_token = None # Remove old unsplit token if exists, can be removed if all tokens are stored split if background_token is not None: try: PushTokens.objects(PushTokens.username == username, PushTokens.domain == domain, PushTokens.device_token == contact_params['pn_tok']).if_exists().delete() except LWTException: pass PushTokens.create(username=username, domain=domain, device_id=contact_params['pn_device'], device_token=token, background_token=background_token, platform=contact_params['pn_type'], silent=contact_params['pn_silent'], app=contact_params['pn_app'], user_agent=user_agent) @run_in_thread('cassandra') def remove(self, account, device_token): username, domain = account.split('@', 1) try: device_token = device_token.split('#')[0] except IndexError: pass try: PushTokens.objects(PushTokens.username == username, PushTokens.domain == domain, PushTokens.device_token == device_token).if_exists().delete() except LWTException: pass -class TokenStorage(object): - __metaclass__ = Singleton - +class TokenStorage(object, metaclass=Singleton): def __new__(self): if CASSANDRA_MODULES_AVAILABLE and CassandraConfig.cluster_contact_points: return CassandraStorage() else: return FileStorage() diff --git a/sylk/applications/webrtcgateway/web.py b/sylk/applications/webrtcgateway/web.py index 01e46fd..f526943 100644 --- a/sylk/applications/webrtcgateway/web.py +++ b/sylk/applications/webrtcgateway/web.py @@ -1,201 +1,197 @@ import json from application.python.types import Singleton from autobahn.twisted.resource import WebSocketResource from twisted.internet import defer, reactor from twisted.python.failure import Failure from twisted.web.server import Site from werkzeug.exceptions import Forbidden, NotFound from werkzeug.utils import secure_filename from sylk import __version__ as sylk_version from sylk.resources import Resources from sylk.web import File, Klein, StaticFileResource, server from . import push from .configuration import GeneralConfig, JanusConfig from .factory import SylkWebSocketServerFactory from .janus import JanusBackend from .logger import log from .models import sylkrtc from .protocol import SYLK_WS_PROTOCOL from .storage import TokenStorage __all__ = 'WebHandler', 'AdminWebHandler' class FileUploadRequest(object): def __init__(self, shared_file, content): self.deferred = defer.Deferred() self.shared_file = shared_file self.content = content self.had_error = False -class WebRTCGatewayWeb(object): - __metaclass__ = Singleton - +class WebRTCGatewayWeb(object, metaclass=Singleton): app = Klein() def __init__(self, ws_factory): self._resource = self.app.resource() self._ws_resource = WebSocketResource(ws_factory) self._ws_factory = ws_factory @property def resource(self): return self._resource @app.route('/', branch=True) def index(self, request): return StaticFileResource(Resources.get('html/webrtcgateway/')) @app.route('/ws') def ws(self, request): return self._ws_resource @app.route('/filesharing///', methods=['OPTIONS', 'POST', 'GET']) def filesharing(self, request, conference, session_id, filename): conference_uri = conference.lower() if conference_uri in self._ws_factory.videorooms: videoroom = self._ws_factory.videorooms[conference_uri] if session_id in videoroom: request.setHeader('Access-Control-Allow-Origin', '*') request.setHeader('Access-Control-Allow-Headers', 'content-type') method = request.method.upper() session = videoroom[session_id] if method == 'POST': def log_result(result): if isinstance(result, Failure): videoroom.log.warning('{file.uploader.uri} failed to upload {file.filename}: {error}'.format(file=upload_request.shared_file, error=result.value)) else: videoroom.log.info('{file.uploader.uri} has uploaded {file.filename}'.format(file=upload_request.shared_file)) return result filename = secure_filename(filename) filesize = int(request.getHeader('Content-Length')) shared_file = sylkrtc.SharedFile(filename=filename, filesize=filesize, uploader=dict(uri=session.account.id, display_name=session.account.display_name), session=session_id) session.owner.log.info('wants to upload file {filename} to video room {conference_uri} with session {session_id}'.format(filename=filename, conference_uri=conference_uri, session_id=session_id)) upload_request = FileUploadRequest(shared_file, request.content) videoroom.add_file(upload_request) upload_request.deferred.addBoth(log_result) return upload_request.deferred elif method == 'GET': filename = secure_filename(filename) session.owner.log.info('wants to download file {filename} from video room {conference_uri} with session {session_id}'.format(filename=filename, conference_uri=conference_uri, session_id=session_id)) try: path = videoroom.get_file(filename) except LookupError as e: videoroom.log.warning('{session.account.id} failed to download {filename}: {error}'.format(session=session, filename=filename, error=e)) raise NotFound() else: videoroom.log.info('{session.account.id} is downloading {filename}'.format(session=session, filename=filename)) request.setHeader('Content-Disposition', 'attachment;filename=%s' % filename) return File(path) else: return 'OK' raise Forbidden() class WebHandler(object): def __init__(self): self.backend = None self.factory = None self.resource = None self.web = None def start(self): ws_url = 'ws' + server.url[4:] + '/webrtcgateway/ws' self.factory = SylkWebSocketServerFactory(ws_url, protocols=[SYLK_WS_PROTOCOL], server='SylkServer/%s' % sylk_version) self.factory.setProtocolOptions(allowedOrigins=GeneralConfig.web_origins, allowNullOrigin=GeneralConfig.web_origins == ['*'], autoPingInterval=GeneralConfig.websocket_ping_interval, autoPingTimeout=GeneralConfig.websocket_ping_interval/2) self.web = WebRTCGatewayWeb(self.factory) server.register_resource('webrtcgateway', self.web.resource) log.info('WebSocket handler started at %s' % ws_url) log.info('Allowed web origins: %s' % ', '.join(GeneralConfig.web_origins)) log.info('Allowed SIP domains: %s' % ', '.join(GeneralConfig.sip_domains)) log.info('Using Janus API: %s' % JanusConfig.api_url) self.backend = JanusBackend() self.backend.start() def stop(self): if self.factory is not None: for conn in self.factory.connections.copy(): conn.dropConnection(abort=True) self.factory = None if self.backend is not None: self.backend.stop() self.backend = None # TODO: This implementation is a prototype. Moving forward it probably makes sense to provide admin API # capabilities for other applications too. This could be done in a number of ways: # # * On the main web server, under a /admin/ parent route. # * On a separate web server, which could listen on a different IP and port. # # In either case, HTTPS aside, a token based authentication mechanism would be desired. # Which one is best is not 100% clear at this point. class AuthError(Exception): pass -class AdminWebHandler(object): - __metaclass__ = Singleton - +class AdminWebHandler(object, metaclass=Singleton): app = Klein() def __init__(self): self.listener = None def start(self): host, port = GeneralConfig.http_management_interface # noinspection PyUnresolvedReferences self.listener = reactor.listenTCP(port, Site(self.app.resource()), interface=host) log.info('Admin web handler started at http://%s:%d' % (host, port)) def stop(self): if self.listener is not None: self.listener.stopListening() self.listener = None # Admin web API def _check_auth(self, request): auth_secret = GeneralConfig.http_management_auth_secret if auth_secret: auth_headers = request.requestHeaders.getRawHeaders('Authorization', default=None) if not auth_headers or auth_headers[0] != auth_secret: raise AuthError() @app.handle_errors(AuthError) def auth_error(self, request, failure): request.setResponseCode(403) return 'Authentication error' @app.route('/tokens/') def get_tokens(self, request, account): self._check_auth(request) request.setHeader('Content-Type', 'application/json') storage = TokenStorage() tokens = storage[account] if isinstance(tokens, defer.Deferred): return tokens.addCallback(lambda result: json.dumps({'tokens': result})) else: return json.dumps({'tokens': tokens}) @app.route('/tokens//', methods=['DELETE']) def process_token(self, request, account, device_token): self._check_auth(request) request.setHeader('Content-Type', 'application/json') storage = TokenStorage() if request.method == 'DELETE': storage.remove(account, device_token) return json.dumps({'success': True}) diff --git a/sylk/applications/xmppgateway/__init__.py b/sylk/applications/xmppgateway/__init__.py index c206607..43946e9 100644 --- a/sylk/applications/xmppgateway/__init__.py +++ b/sylk/applications/xmppgateway/__init__.py @@ -1,571 +1,570 @@ from application.notification import IObserver, NotificationCenter from application.python import Null from sipsimple.core import SIPURI, SIPCoreError from sipsimple.payloads import ParserError from sipsimple.payloads.iscomposing import IsComposingDocument, IsComposingMessage, State, LastActive, Refresh, ContentType from sipsimple.streams.msrp.chat import CPIMPayload, CPIMParserError from sipsimple.threading.green import run_in_green_thread from sipsimple.util import ISOTimestamp - -from zope.interface import implements +from zope.interface import implementer from sylk.applications import SylkApplication from sylk.applications.xmppgateway.configuration import XMPPGatewayConfig from sylk.applications.xmppgateway.datatypes import Identity, FrozenURI, generate_sylk_resource, decode_resource from sylk.applications.xmppgateway.im import SIPMessageSender, SIPMessageError, ChatSessionHandler from sylk.applications.xmppgateway.logger import log from sylk.applications.xmppgateway.presence import S2XPresenceHandler, X2SPresenceHandler from sylk.applications.xmppgateway.media import MediaSessionHandler from sylk.applications.xmppgateway.muc import X2SMucInvitationHandler, S2XMucInvitationHandler, X2SMucHandler from sylk.applications.xmppgateway.util import format_uri from sylk.applications.xmppgateway.xmpp import XMPPManager from sylk.applications.xmppgateway.xmpp.session import XMPPChatSession from sylk.applications.xmppgateway.xmpp.stanzas import ChatMessage, ChatComposingIndication, NormalMessage +@implementer(IObserver) class XMPPGatewayApplication(SylkApplication): - implements(IObserver) def __init__(self): self.xmpp_manager = XMPPManager() self.pending_sessions = {} self.chat_sessions = set() self.media_sessions = set() self.s2x_muc_sessions = {} self.x2s_muc_sessions = {} self.s2x_presence_subscriptions = {} self.x2s_presence_subscriptions = {} self.s2x_muc_add_participant_handlers = {} self.x2s_muc_add_participant_handlers = {} def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.xmpp_manager) notification_center.add_observer(self, name='JingleSessionNewIncoming') self.xmpp_manager.start() def stop(self): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=self.xmpp_manager) notification_center.add_observer(self, name='JingleSessionNewIncoming') self.xmpp_manager.stop() def incoming_session(self, session): stream_types = set(stream.type for stream in session.proposed_streams) if 'chat' in stream_types: log.info('SIP/MSRP chat session from %s to %s' % (session.remote_identity.uri, session.local_identity.uri)) self.incoming_chat_session(session) elif 'audio' in stream_types: log.info('SIP audio session from %s to %s' % (session.remote_identity.uri, session.local_identity.uri)) self.incoming_media_session(session) else: log.info('SIP %s session from %s to %s rejected: unsupported media ' % (stream_types, session.remote_identity.uri, session.local_identity.uri)) session.reject(488) def incoming_chat_session(self, session): # Check if this session is really an invitation to add a participant to a conference room / muc if session.remote_identity.uri.host in self.xmpp_manager.muc_domains and 'isfocus' in session._invitation.remote_contact_header.parameters: try: referred_by_uri = SIPURI.parse(session.transfer_info.referred_by) except SIPCoreError: log.info("SIP multiparty session invitation %s failed: invalid Referred-By header" % session.call_id) session.reject(488) return muc_uri = FrozenURI(session.remote_identity.uri.user, session.remote_identity.uri.host) inviter_uri = FrozenURI(referred_by_uri.user, referred_by_uri.host) recipient_uri = FrozenURI(session.local_identity.uri.user, session.local_identity.uri.host) sender = Identity(muc_uri) recipient = Identity(recipient_uri) inviter = Identity(inviter_uri) try: handler = self.s2x_muc_add_participant_handlers[(muc_uri, recipient_uri)] except KeyError: handler = S2XMucInvitationHandler(session, sender, recipient, inviter) self.s2x_muc_add_participant_handlers[(muc_uri, recipient_uri)] = handler NotificationCenter().add_observer(self, sender=handler) handler.start() else: log.info("SIP multiparty session invitation %s failed: there is another invitation in progress from %s to %s" % (session.call_id, format_uri(inviter_uri, 'sip'), format_uri(recipient_uri, 'xmpp'))) session.reject(480) return # Check domain if session.remote_identity.uri.host not in XMPPGatewayConfig.domains: log.info('Session rejected: From domain is not a local XMPP domain') session.reject(606, 'Not Acceptable') return # Get URI representing the SIP side contact_uri = session._invitation.remote_contact_header.uri if contact_uri.parameters.get('gr') is not None: sip_leg_uri = FrozenURI(contact_uri.user, contact_uri.host, contact_uri.parameters.get('gr')) else: tmp = session.remote_identity.uri sip_leg_uri = FrozenURI(tmp.user, tmp.host, generate_sylk_resource()) # Get URI representing the XMPP side request_uri = session.request_uri remote_resource = request_uri.parameters.get('gr', None) if remote_resource is not None: try: remote_resource = decode_resource(remote_resource) except (TypeError, UnicodeError): remote_resource = None xmpp_leg_uri = FrozenURI(request_uri.user, request_uri.host, remote_resource) try: handler = self.pending_sessions[(sip_leg_uri, xmpp_leg_uri)] except KeyError: pass else: # There is another pending session with same identifiers, can't accept this one log.info('Session rejected: other session with same identifiers in progress') session.reject(488) return sip_identity = Identity(sip_leg_uri, session.remote_identity.display_name) handler = ChatSessionHandler.new_from_sip_session(sip_identity, session) NotificationCenter().add_observer(self, sender=handler) key = (sip_leg_uri, xmpp_leg_uri) self.pending_sessions[key] = handler if xmpp_leg_uri.resource is not None: # Incoming session target contained GRUU, so create XMPPChatSession immediately xmpp_session = XMPPChatSession(local_identity=handler.sip_identity, remote_identity=Identity(xmpp_leg_uri)) handler.xmpp_identity = xmpp_session.remote_identity handler.xmpp_session = xmpp_session def incoming_media_session(self, session): if session.remote_identity.uri.host not in self.xmpp_manager.domains|self.xmpp_manager.muc_domains: log.info('Session rejected: From domain is not a local XMPP domain') session.reject(403) return handler = MediaSessionHandler.new_from_sip_session(session) if handler is not None: NotificationCenter().add_observer(self, sender=handler) def incoming_subscription(self, subscribe_request, data): from_header = data.headers.get('From', Null) to_header = data.headers.get('To', Null) if Null in (from_header, to_header): subscribe_request.reject(400) return if XMPPGatewayConfig.log_presence: log.info('SIP subscription from %s to %s' % (format_uri(from_header.uri, 'sip'), format_uri(to_header.uri, 'xmpp'))) if subscribe_request.event != 'presence': if XMPPGatewayConfig.log_presence: log.info('SIP subscription rejected: only presence event is supported') subscribe_request.reject(489) return # Check domain remote_identity_uri = data.headers['From'].uri if remote_identity_uri.host not in XMPPGatewayConfig.domains: if XMPPGatewayConfig.log_presence: log.info('SIP subscription rejected: From domain is not a local XMPP domain') subscribe_request.reject(606) return # Get URI representing the SIP side sip_leg_uri = FrozenURI(remote_identity_uri.user, remote_identity_uri.host) # Get URI representing the XMPP side request_uri = data.request_uri xmpp_leg_uri = FrozenURI(request_uri.user, request_uri.host) try: handler = self.s2x_presence_subscriptions[(sip_leg_uri, xmpp_leg_uri)] except KeyError: sip_identity = Identity(sip_leg_uri, data.headers['From'].display_name) xmpp_identity = Identity(xmpp_leg_uri) handler = S2XPresenceHandler(sip_identity, xmpp_identity) self.s2x_presence_subscriptions[(sip_leg_uri, xmpp_leg_uri)] = handler NotificationCenter().add_observer(self, sender=handler) handler.start() handler.add_sip_subscription(subscribe_request) def incoming_referral(self, refer_request, data): refer_request.reject(405) def incoming_message(self, message_request, data): content_type = data.headers.get('Content-Type', Null).content_type from_header = data.headers.get('From', Null) to_header = data.headers.get('To', Null) if Null in (content_type, from_header, to_header): message_request.answer(400) return # Check domain if from_header.uri.host not in XMPPGatewayConfig.domains: log.info('Message rejected: From domain is not a local XMPP domain') message_request.answer(606) return if content_type == 'message/cpim': try: cpim_message = CPIMPayload.decode(data.body) except CPIMParserError: log.info('Message rejected: CPIM parse error') message_request.answer(400) return else: body = cpim_message.content.decode('utf-8') content_type = cpim_message.content_type sender = cpim_message.sender or from_header from_uri = sender.uri else: body = data.body.decode('utf-8') from_uri = from_header.uri log.info('SIP %s message from %s to %s' % (content_type, from_header.uri, 'xmpp:%s@%s' % (to_header.uri.user, to_header.uri.host))) to_uri = str(to_header.uri) message_request.answer(200) if from_uri.parameters.get('gr', None) is None: from_uri = SIPURI.new(from_uri) from_uri.parameters['gr'] = generate_sylk_resource() sender = Identity(FrozenURI.parse(from_uri)) recipient = Identity(FrozenURI.parse(to_uri)) if content_type in ('text/plain', 'text/html'): if content_type == 'text/plain': html_body = None else: html_body = body body = None if XMPPGatewayConfig.use_msrp_for_chat: message = NormalMessage(sender, recipient, body, html_body, use_receipt=False) self.xmpp_manager.send_stanza(message) else: message = ChatMessage(sender, recipient, body, html_body, use_receipt=False) self.xmpp_manager.send_stanza(message) elif content_type == IsComposingDocument.content_type: if not XMPPGatewayConfig.use_msrp_for_chat: try: msg = IsComposingMessage.parse(body) except ParserError: pass else: state = 'composing' if msg.state == 'active' else 'paused' message = ChatComposingIndication(sender, recipient, state, use_receipt=False) self.xmpp_manager.send_stanza(message) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) # Out of band XMPP stanza handling @run_in_green_thread def _NH_XMPPGotChatMessage(self, notification): # This notification is only processed here untill the ChatSessionHandler # has both (SIP and XMPP) sessions established message = notification.data.message content_type = 'text/html' if message.html_body else 'text/plain' sender = message.sender recipient = message.recipient sip_leg_uri = FrozenURI.new(recipient.uri) xmpp_leg_uri = FrozenURI.new(sender.uri) log.info('XMPP %s message %s from xmpp:%s to sip:%s, use_receipt=%s' % (content_type, message.id, xmpp_leg_uri, sip_leg_uri, message.use_receipt)) if XMPPGatewayConfig.use_msrp_for_chat: if recipient.uri.resource is None: # If recipient resource is not set the session is started from # the XMPP side try: handler = self.pending_sessions[(sip_leg_uri, xmpp_leg_uri)] handler.enqueue_xmpp_message(message) except KeyError: # Check if we have any already open chat session and dispatch it there try: handler = next(h for h in self.chat_sessions if h.xmpp_identity.uri.user == xmpp_leg_uri.user and h.xmpp_identity.uri.host == xmpp_leg_uri.host and h.sip_identity.uri.user == sip_leg_uri.user and h.sip_identity.uri.host == sip_leg_uri.host) except StopIteration: # Not found, need to create a new handler and a outgoing SIP session xmpp_identity = Identity(xmpp_leg_uri) handler = ChatSessionHandler.new_from_xmpp_stanza(xmpp_identity, sip_leg_uri) key = (sip_leg_uri, xmpp_leg_uri) self.pending_sessions[key] = handler NotificationCenter().add_observer(self, sender=handler) handler.enqueue_xmpp_message(message) else: # Find handler pending XMPP confirmation sip_leg_uri = FrozenURI.new(recipient.uri) xmpp_leg_uri = FrozenURI(sender.uri.user, sender.uri.host) try: handler = self.pending_sessions[(sip_leg_uri, xmpp_leg_uri)] except KeyError: # Find handler pending XMPP confirmation sip_leg_uri = FrozenURI(recipient.uri.user, recipient.uri.host) xmpp_leg_uri = FrozenURI.new(sender.uri) try: handler = self.pending_sessions[(sip_leg_uri, xmpp_leg_uri)] except KeyError: # Try harder, maybe the XMPP client changed his from try: handler = next(h for h in self.chat_sessions if h.xmpp_identity.uri.user == xmpp_leg_uri.user and h.xmpp_identity.uri.host == xmpp_leg_uri.host and h.sip_identity.uri.user == sip_leg_uri.user and h.sip_identity.uri.host == sip_leg_uri.host) except StopIteration: # It's a new XMPP session to a full JID, disregard the full JID and start a new SIP session to the bare JID xmpp_identity = Identity(xmpp_leg_uri) handler = ChatSessionHandler.new_from_xmpp_stanza(xmpp_identity, sip_leg_uri) key = (sip_leg_uri, xmpp_leg_uri) self.pending_sessions[key] = handler NotificationCenter().add_observer(self, sender=handler) handler.enqueue_xmpp_message(message) else: # Found handle, create XMPP session and establish session session = XMPPChatSession(local_identity=recipient, remote_identity=sender) handler.enqueue_xmpp_message(message) handler.xmpp_identity = session.remote_identity handler.xmpp_session = session else: sip_message_sender = SIPMessageSender(message, use_cpim=XMPPGatewayConfig.use_cpim) try: sip_message_sender.send().wait() except SIPMessageError as e: # TODO report back an error stanza if XMPPGatewayConfig.log_messages: log.error('SIP message from %s to %s failed: %s (%s)' % (xmpp_leg_uri, sip_leg_uri, e.reason, e.code)) @run_in_green_thread def _NH_XMPPGotNormalMessage(self, notification): message = notification.data.message sip_message_sender = SIPMessageSender(message, use_cpim=XMPPGatewayConfig.use_cpim) try: sip_message_sender.send().wait() except SIPMessageError as e: # TODO report back an error stanza if XMPPGatewayConfig.log_messages: log.error('SIP message from %s to %s failed: %s (%s)' % (xmpp_leg_uri, sip_leg_uri, e.reason, e.code)) @run_in_green_thread def _NH_XMPPGotComposingIndication(self, notification): composing_indication = notification.data.composing_indication sender = composing_indication.sender recipient = composing_indication.recipient if not XMPPGatewayConfig.use_msrp_for_chat: state = 'active' if composing_indication.state == 'composing' else 'idle' try: interval = composing_indication.interval except AttributeError: interval = 60 body = IsComposingMessage(state=State(state), refresh=Refresh(interval), last_active=LastActive(ISOTimestamp.now()), content_type=ContentType('text')).toxml() message = NormalMessage(sender, recipient, body) sip_message_sender = SIPMessageSender(message, content_type=IsComposingDocument.content_type, use_cpim=XMPPGatewayConfig.use_cpim) if XMPPGatewayConfig.log_iscomposing: log.info('xmpp:%s to sip:%s chat is %s' % (sender, recipient, composing_indication.state)) try: sip_message_sender.send().wait() except SIPMessageError as e: if XMPPGatewayConfig.log_iscomposing: log.error('SIP Message from %s to %s failed: %s (%s)' % (sender, recipient, e.reason, e.code)) def _NH_XMPPGotPresenceSubscriptionRequest(self, notification): stanza = notification.data.stanza # Disregard the resource part, the presence request could be a probe instead of a subscribe sender_uri = stanza.sender.uri sender_uri_bare = FrozenURI(sender_uri.user, sender_uri.host) try: handler = self.x2s_presence_subscriptions[(sender_uri_bare, stanza.recipient.uri)] except KeyError: xmpp_identity = stanza.sender xmpp_identity.uri = sender_uri_bare sip_identity = stanza.recipient handler = X2SPresenceHandler(sip_identity, xmpp_identity) self.x2s_presence_subscriptions[(sender_uri_bare, stanza.recipient.uri)] = handler notification.center.add_observer(self, sender=handler) handler.start() def _NH_XMPPGotMucJoinRequest(self, notification): stanza = notification.data.stanza muc_uri = FrozenURI(stanza.recipient.uri.user, stanza.recipient.uri.host) nickname = stanza.recipient.uri.resource try: handler = self.x2s_muc_sessions[(stanza.sender.uri, muc_uri)] except KeyError: xmpp_identity = stanza.sender sip_identity = stanza.recipient sip_identity.uri = muc_uri handler = X2SMucHandler(sip_identity, xmpp_identity, nickname) handler._first_stanza = stanza notification.center.add_observer(self, sender=handler) handler.start() # Check if there was a pending join request on the SIP side try: handler = self.s2x_muc_add_participant_handlers[(muc_uri, FrozenURI(stanza.sender.uri.user, stanza.sender.uri.host))] except KeyError: pass else: handler.stop() def _NH_XMPPGotMucAddParticipantRequest(self, notification): sender = notification.data.sender recipient = notification.data.recipient participant = notification.data.participant muc_uri = FrozenURI(recipient.uri.user, recipient.uri.host) sender_uri = FrozenURI(sender.uri.user, sender.uri.host) participant_uri = FrozenURI(participant.uri.user, participant.uri.host) sender = Identity(sender_uri) recipient = Identity(muc_uri) participant = Identity(participant_uri) try: handler = self.x2s_muc_add_participant_handlers[(muc_uri, participant_uri)] except KeyError: handler = X2SMucInvitationHandler(sender, recipient, participant) self.x2s_muc_add_participant_handlers[(muc_uri, participant_uri)] = handler notification.center.add_observer(self, sender=handler) handler.start() # Chat session handling def _NH_ChatSessionDidStart(self, notification): handler = notification.sender log.info('Chat session established sip:%s <--> xmpp:%s' % (handler.sip_identity.uri, handler.xmpp_identity.uri)) - for k,v in self.pending_sessions.items(): + for k,v in list(self.pending_sessions.items()): if v is handler: del self.pending_sessions[k] break self.chat_sessions.add(handler) def _NH_ChatSessionDidEnd(self, notification): handler = notification.sender log.info('Chat session ended sip:%s <--> xmpp:%s' % (handler.sip_identity.uri, handler.xmpp_identity.uri)) self.chat_sessions.remove(handler) notification.center.remove_observer(self, sender=handler) def _NH_ChatSessionDidFail(self, notification): handler = notification.sender uris = None - for k,v in self.pending_sessions.items(): + for k,v in list(self.pending_sessions.items()): if v is handler: uris = k del self.pending_sessions[k] break sip_uri, xmpp_uri = uris log.info('Chat session failed sip:%s <--> xmpp:%s (%s)' % (sip_uri, xmpp_uri, notification.data.reason)) notification.center.remove_observer(self, sender=handler) # Presence handling def _NH_S2XPresenceHandlerDidStart(self, notification): handler = notification.sender if XMPPGatewayConfig.log_presence: log.info('Presence flow 0x%x established %s --> %s' % (id(handler), format_uri(handler.sip_identity.uri, 'sip'), format_uri(handler.xmpp_identity.uri, 'xmpp'))) log.info('%d SIP --> XMPP and %d XMPP --> SIP presence flows are active' % (len(self.s2x_presence_subscriptions), len(self.x2s_presence_subscriptions))) def _NH_S2XPresenceHandlerDidEnd(self, notification): handler = notification.sender self.s2x_presence_subscriptions.pop((handler.sip_identity.uri, handler.xmpp_identity.uri), None) notification.center.remove_observer(self, sender=handler) if XMPPGatewayConfig.log_presence: log.info('Presence flow 0x%x ended %s --> %s' % (id(handler), format_uri(handler.sip_identity.uri, 'sip'), format_uri(handler.xmpp_identity.uri, 'xmpp'))) log.info('%d SIP --> XMPP and %d XMPP --> SIP presence flows are active' % (len(self.s2x_presence_subscriptions), len(self.x2s_presence_subscriptions))) def _NH_X2SPresenceHandlerDidStart(self, notification): handler = notification.sender if XMPPGatewayConfig.log_presence: log.info('Presence flow 0x%x established %s --> %s' % (id(handler), format_uri(handler.xmpp_identity.uri, 'xmpp'), format_uri(handler.sip_identity.uri, 'sip'))) log.info('%d SIP --> XMPP and %d XMPP --> SIP presence flows are active' % (len(self.s2x_presence_subscriptions), len(self.x2s_presence_subscriptions))) def _NH_X2SPresenceHandlerDidEnd(self, notification): handler = notification.sender self.x2s_presence_subscriptions.pop((handler.xmpp_identity.uri, handler.sip_identity.uri), None) notification.center.remove_observer(self, sender=handler) if XMPPGatewayConfig.log_presence: log.info('Presence flow 0x%x ended %s --> %s' % (id(handler), format_uri(handler.xmpp_identity.uri, 'xmpp'), format_uri(handler.sip_identity.uri, 'sip'))) log.info('%d SIP --> XMPP and %d XMPP --> SIP presence flows are active' % (len(self.s2x_presence_subscriptions), len(self.x2s_presence_subscriptions))) # MUC handling def _NH_X2SMucHandlerDidStart(self, notification): handler = notification.sender log.info('Multiparty session established xmpp:%s --> sip:%s' % (handler.xmpp_identity.uri, handler.sip_identity.uri)) self.x2s_muc_sessions[(handler.xmpp_identity.uri, handler.sip_identity.uri)] = handler def _NH_X2SMucHandlerDidEnd(self, notification): handler = notification.sender log.info('Multiparty session ended xmpp:%s --> sip:%s' % (handler.xmpp_identity.uri, handler.sip_identity.uri)) self.x2s_muc_sessions.pop((handler.xmpp_identity.uri, handler.sip_identity.uri), None) notification.center.remove_observer(self, sender=handler) def _NH_X2SMucInvitationHandlerDidStart(self, notification): handler = notification.sender sender_uri = handler.sender.uri muc_uri = handler.recipient.uri participant_uri = handler.participant.uri log.info('%s invited %s to multiparty chat %s' % (format_uri(sender_uri, 'xmpp'), format_uri(participant_uri), format_uri(muc_uri, 'sip'))) def _NH_X2SMucInvitationHandlerDidEnd(self, notification): handler = notification.sender sender_uri = handler.sender.uri muc_uri = handler.recipient.uri participant_uri = handler.participant.uri log.info('%s added %s to multiparty chat %s' % (format_uri(sender_uri, 'xmpp'), format_uri(participant_uri), format_uri(muc_uri, 'sip'))) del self.x2s_muc_add_participant_handlers[(muc_uri, participant_uri)] notification.center.remove_observer(self, sender=handler) def _NH_X2SMucInvitationHandlerDidFail(self, notification): handler = notification.sender sender_uri = handler.sender.uri muc_uri = handler.recipient.uri participant_uri = handler.participant.uri log.info('%s could not add %s to multiparty chat %s: %s' % (format_uri(sender_uri, 'xmpp'), format_uri(participant_uri), format_uri(muc_uri, 'sip'), notification.data.failure)) del self.x2s_muc_add_participant_handlers[(muc_uri, participant_uri)] notification.center.remove_observer(self, sender=handler) def _NH_S2XMucInvitationHandlerDidStart(self, notification): handler = notification.sender muc_uri = handler.sender.uri inviter_uri = handler.inviter.uri recipient_uri = handler.recipient.uri log.info("%s invited %s to multiparty chat %s" % (format_uri(inviter_uri, 'sip'), format_uri(recipient_uri, 'xmpp'), format_uri(muc_uri, 'sip'))) def _NH_S2XMucInvitationHandlerDidEnd(self, notification): handler = notification.sender muc_uri = handler.sender.uri inviter_uri = handler.inviter.uri recipient_uri = handler.recipient.uri log.info('%s added %s to multiparty chat %s' % (format_uri(inviter_uri, 'sip'), format_uri(recipient_uri, 'xmpp'), format_uri(muc_uri, 'sip'))) del self.s2x_muc_add_participant_handlers[(muc_uri, recipient_uri)] notification.center.remove_observer(self, sender=handler) def _NH_S2XMucInvitationHandlerDidFail(self, notification): handler = notification.sender muc_uri = handler.sender.uri inviter_uri = handler.inviter.uri recipient_uri = handler.recipient.uri log.info('%s could not add %s to multiparty chat %s: %s' % (format_uri(inviter_uri, 'sip'), format_uri(recipient_uri, 'xmpp'), format_uri(muc_uri, 'sip'), str(notification.data.failure))) del self.s2x_muc_add_participant_handlers[(muc_uri, recipient_uri)] notification.center.remove_observer(self, sender=handler) # Media sessions def _NH_JingleSessionNewIncoming(self, notification): session = notification.sender handler = MediaSessionHandler.new_from_jingle_session(session) if handler is not None: notification.center.add_observer(self, sender=handler) def _NH_MediaSessionHandlerDidStart(self, notification): handler = notification.sender log.info('Media session started sip:%s <--> xmpp:%s' % (handler.sip_identity.uri, handler.xmpp_identity.uri)) self.media_sessions.add(handler) def _NH_MediaSessionHandlerDidEnd(self, notification): handler = notification.sender log.info('Media session ended sip:%s <--> xmpp:%s' % (handler.sip_identity.uri, handler.xmpp_identity.uri)) self.media_sessions.remove(handler) notification.center.remove_observer(self, sender=handler) def _NH_MediaSessionHandlerDidFail(self, notification): handler = notification.sender log.info('Media session failed sip:%s <--> xmpp:%s' % (handler.sip_identity.uri, handler.xmpp_identity.uri)) notification.center.remove_observer(self, sender=handler) diff --git a/sylk/applications/xmppgateway/configuration.py b/sylk/applications/xmppgateway/configuration.py index a424453..b7f6089 100644 --- a/sylk/applications/xmppgateway/configuration.py +++ b/sylk/applications/xmppgateway/configuration.py @@ -1,28 +1,26 @@ from application.system import host from application.configuration import ConfigSection, ConfigSetting from application.configuration.datatypes import StringList from sipsimple.configuration.datatypes import NonNegativeInteger - -from sylk.configuration.datatypes import IPAddress, Port -from sylk.configuration.datatypes import Path +from sylk.configuration.datatypes import IPAddress, Port, Path class XMPPGatewayConfig(ConfigSection): __cfgfile__ = 'xmppgateway.ini' __section__ = 'general' local_ip = ConfigSetting(type=IPAddress, value=IPAddress(host.default_ip)) local_port = ConfigSetting(type=Port, value=5269) trace_xmpp = False log_presence = False log_messages = False log_iscomposing = False transport = ConfigSetting(type=str, value='tls') ca_file = ConfigSetting(type=Path, value=Path('/etc/sylkserver/tls/ca.crt')) certificate = ConfigSetting(type=Path, value=Path('/etc/sylkserver/tls/default.crt')) domains = ConfigSetting(type=StringList, value=[]) muc_prefix = 'conference' sip_session_timeout = ConfigSetting(type=NonNegativeInteger, value=86400) use_msrp_for_chat = True use_cpim = True diff --git a/sylk/applications/xmppgateway/datatypes.py b/sylk/applications/xmppgateway/datatypes.py index f8f9a2f..873df31 100644 --- a/sylk/applications/xmppgateway/datatypes.py +++ b/sylk/applications/xmppgateway/datatypes.py @@ -1,156 +1,156 @@ import hashlib import random import string from application.python.descriptor import WriteOnceAttribute from sipsimple.core import BaseSIPURI, SIPURI, SIPCoreError from twisted.words.protocols.jabber.jid import JID -sylkserver_prefix = hashlib.md5('sylkserver').hexdigest() +sylkserver_prefix = hashlib.md5(b'sylkserver').hexdigest() def generate_sylk_resource(): r = 'sylk-'+''.join(random.choice(string.ascii_letters+string.digits) for x in range(32)) return r.encode('hex') def is_sylk_resource(r): if r.startswith('urn:uuid:') or len(r) != 74: return False try: decoded = r.decode('hex') except TypeError: return False else: return decoded.startswith('sylk-') def encode_resource(r): return r.encode('utf-8').encode('hex') def decode_resource(r): return r.decode('hex').decode('utf-8') class BaseURI(object): def __init__(self, user, host, resource=None): self.user = user self.host = host self.resource = resource @classmethod def parse(cls, value): if isinstance(value, BaseSIPURI): - user = unicode(value.user) - host = unicode(value.host) - resource = unicode(value.parameters.get('gr', '')) or None + user = str(value.user) + host = str(value.host) + resource = str(value.parameters.get('gr', '')) or None return cls(user, host, resource) elif isinstance(value, JID): user = value.user host = value.host resource = value.resource return cls(user, host, resource) - elif not isinstance(value, basestring): + elif not isinstance(value, str): raise TypeError('uri needs to be a string') if not value.startswith(('sip:', 'sips:', 'xmpp:')): raise ValueError('invalid uri scheme for %s' % value) if value.startswith(('sip:', 'sips:')): try: uri = SIPURI.parse(value) except SIPCoreError: raise ValueError('invalid SIP uri: %s' % value) - user = unicode(uri.user) - host = unicode(uri.host) - resource = unicode(uri.parameters.get('gr', '')) or None + user = str(uri.user) + host = str(uri.host) + resource = str(uri.parameters.get('gr', '')) or None else: try: jid = JID(value[5:]) except Exception: raise ValueError('invalid XMPP uri: %s' % value) user = jid.user host = jid.host resource = jid.resource return cls(user, host, resource) @classmethod def new(cls, uri): if not isinstance(uri, BaseURI): raise TypeError('%s is not a valid URI type' % type(uri)) return cls(uri.user, uri.host, uri.resource) def as_sip_uri(self): uri = SIPURI(user=str(self.user), host=str(self.host)) if self.resource is not None: uri.parameters['gr'] = self.resource.encode('utf-8') return uri def as_xmpp_jid(self): return JID(tuple=(self.user, self.host, self.resource)) def __eq__(self, other): if isinstance(other, BaseURI): return self.user == other.user and self.host == other.host and self.resource == other.resource - elif isinstance(other, basestring): + elif isinstance(other, str): try: other = BaseURI.parse(other) except ValueError: return False else: return self.user == other.user and self.host == other.host and self.resource == other.resource else: return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __repr__(self): return '%s(user=%r, host=%r, resource=%r)' % (self.__class__.__name__, self.user, self.host, self.resource) def __unicode__(self): - return u'%s@%s' % (self.user, self.host) + return '%s@%s' % (self.user, self.host) def __str__(self): - return unicode(self).encode('utf-8') + return str(self).encode('utf-8') class URI(BaseURI): pass class FrozenURI(BaseURI): user = WriteOnceAttribute() host = WriteOnceAttribute() resource = WriteOnceAttribute() def __hash__(self): return hash((self.user, self.host, self.resource)) class Identity(object): def __init__(self, uri, display_name=None): self.uri = uri self.display_name = display_name def __eq__(self, other): if isinstance(other, Identity): return self.uri == other.uri and self.display_name == other.display_name else: return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __unicode__(self): if self.display_name is not None: - return u'%s <%s>' % (self.display_name, self.uri) + return '%s <%s>' % (self.display_name, self.uri) else: - return u'%s' % self.uri + return '%s' % self.uri def __str__(self): - return unicode(self).encode('utf-8') + return str(self).encode('utf-8') diff --git a/sylk/applications/xmppgateway/im.py b/sylk/applications/xmppgateway/im.py index ac739b1..53caec4 100644 --- a/sylk/applications/xmppgateway/im.py +++ b/sylk/applications/xmppgateway/im.py @@ -1,462 +1,462 @@ from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.descriptor import WriteOnceAttribute from collections import deque from eventlib import coros from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import SIPURI from sipsimple.core import ContactHeader, FromHeader, RouteHeader, ToHeader from sipsimple.core import Message as SIPMessageRequest from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.streams import MediaStreamRegistry from sipsimple.streams.msrp.chat import CPIMPayload, ChatIdentity, CPIMHeader from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import run_in_green_thread, run_in_waitable_green_thread from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.applications.xmppgateway.configuration import XMPPGatewayConfig from sylk.applications.xmppgateway.datatypes import Identity, FrozenURI, generate_sylk_resource, encode_resource from sylk.applications.xmppgateway.logger import log from sylk.applications.xmppgateway.xmpp import XMPPManager from sylk.applications.xmppgateway.xmpp.session import XMPPChatSession from sylk.applications.xmppgateway.xmpp.stanzas import ChatMessage from sylk.session import Session __all__ = 'ChatSessionHandler', 'SIPMessageSender', 'SIPMessageError' SESSION_TIMEOUT = XMPPGatewayConfig.sip_session_timeout +@implementer(IObserver) class ChatSessionHandler(object): - implements(IObserver) sip_identity = WriteOnceAttribute() xmpp_identity = WriteOnceAttribute() def __init__(self): self.started = False self.ended = False self.sip_session = None self.msrp_stream = None self._sip_session_timer = None self.use_receipts = False self.xmpp_session = None self._xmpp_message_queue = deque() self._pending_msrp_chunks = {} self._pending_xmpp_stanzas = {} @property def started(self): return self.__dict__['started'] @started.setter def started(self, value): old_value = self.__dict__.get('started', False) self.__dict__['started'] = value if not old_value and value: NotificationCenter().post_notification('ChatSessionDidStart', sender=self) self._send_queued_messages() @property def xmpp_session(self): return self.__dict__['xmpp_session'] @xmpp_session.setter def xmpp_session(self, session): self.__dict__['xmpp_session'] = session if session is not None: # Reet SIP session timer in case it's active if self._sip_session_timer is not None and self._sip_session_timer.active(): self._sip_session_timer.reset(SESSION_TIMEOUT) NotificationCenter().add_observer(self, sender=session) session.start() # Reet SIP session timer in case it's active if self._sip_session_timer is not None and self._sip_session_timer.active(): self._sip_session_timer.reset(SESSION_TIMEOUT) @classmethod def new_from_sip_session(cls, sip_identity, session): instance = cls() instance.sip_identity = sip_identity instance._start_incoming_sip_session(session) return instance @classmethod def new_from_xmpp_stanza(cls, xmpp_identity, recipient): instance = cls() instance.xmpp_identity = xmpp_identity instance._start_outgoing_sip_session(recipient) return instance @run_in_green_thread def _start_incoming_sip_session(self, session): self.sip_session = session self.msrp_stream = next(stream for stream in session.proposed_streams if stream.type=='chat') notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.sip_session) notification_center.add_observer(self, sender=self.msrp_stream) self.sip_session.accept([self.msrp_stream]) @run_in_green_thread def _start_outgoing_sip_session(self, target_uri): notification_center = NotificationCenter() # self.xmpp_identity is our local identity from_uri = self.xmpp_identity.uri.as_sip_uri() del from_uri.parameters['gr'] # no GRUU in From header contact_uri = self.xmpp_identity.uri.as_sip_uri() contact_uri.parameters['gr'] = encode_resource(contact_uri.parameters['gr'].decode('utf-8')) to_uri = target_uri.as_sip_uri() lookup = DNSLookup() settings = SIPSimpleSettings() account = DefaultAccount() if account.sip.outbound_proxy is not None: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) else: uri = to_uri try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError: log.warning('DNS lookup error while looking for %s proxy' % uri) notification_center.post_notification('ChatSessionDidFail', sender=self, data=NotificationData(reason='DNS lookup error')) return self.msrp_stream = MediaStreamRegistry.get('chat')() route = routes.pop(0) from_header = FromHeader(from_uri) to_header = ToHeader(to_uri) contact_header = ContactHeader(contact_uri) self.sip_session = Session(account) notification_center.add_observer(self, sender=self.sip_session) notification_center.add_observer(self, sender=self.msrp_stream) self.sip_session.connect(from_header, to_header, contact_header=contact_header, route=route, streams=[self.msrp_stream]) def end(self): if self.ended: return if self._sip_session_timer is not None and self._sip_session_timer.active(): self._sip_session_timer.cancel() self._sip_session_timer = None notification_center = NotificationCenter() if self.sip_session is not None: notification_center.remove_observer(self, sender=self.sip_session) notification_center.remove_observer(self, sender=self.msrp_stream) self.sip_session.end() self.sip_session = None self.msrp_stream = None if self.xmpp_session is not None: notification_center.remove_observer(self, sender=self.xmpp_session) self.xmpp_session.end() self.xmpp_session = None self.ended = True if self.started: notification_center.post_notification('ChatSessionDidEnd', sender=self) else: notification_center.post_notification('ChatSessionDidFail', sender=self, data=NotificationData(reason='Ended before actually started')) def enqueue_xmpp_message(self, message): self._xmpp_message_queue.append(message) if self.started: self._send_queued_messages() def _send_queued_messages(self): sender = None while self._xmpp_message_queue: message = self._xmpp_message_queue.popleft() if message.body is None: continue sender_uri = message.sender.uri.as_sip_uri() sender_uri.parameters['gr'] = encode_resource(sender_uri.parameters['gr'].decode('utf-8')) sender = ChatIdentity(sender_uri) self.msrp_stream.send_message(message.body, 'text/plain', sender=sender, message_id=str(message.id), notify_progress=message.use_receipt) if sender: self.msrp_stream.send_composing_indication('idle', 30, sender=sender) def _inactivity_timeout(self): log.info("Ending SIP session %s due to inactivity" % self.sip_session.call_id) self.sip_session.end() def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidStart(self, notification): log.info("SIP session %s started" % self.sip_session.call_id) self._sip_session_timer = reactor.callLater(SESSION_TIMEOUT, self._inactivity_timeout) if self.sip_session.direction == 'outgoing': # Time to set sip_identity and create the XMPPChatSession contact_uri = self.sip_session._invitation.remote_contact_header.uri if contact_uri.parameters.get('gr') is not None: sip_leg_uri = FrozenURI(contact_uri.user, contact_uri.host, contact_uri.parameters.get('gr')) else: tmp = self.sip_session.remote_identity.uri sip_leg_uri = FrozenURI(tmp.user, tmp.host, generate_sylk_resource()) self.sip_identity = Identity(sip_leg_uri, self.sip_session.remote_identity.display_name) session = XMPPChatSession(local_identity=self.sip_identity, remote_identity=self.xmpp_identity) self.xmpp_session = session # Session is now established on both ends self.started = True # Try to wakeup XMPP clients self.xmpp_session.send_composing_indication('active') self.xmpp_session.send_message(' ', 'text/plain') else: if self.xmpp_session is not None: # Session is now established on both ends self.started = True # Try to wakeup XMPP clients self.xmpp_session.send_composing_indication('active') self.xmpp_session.send_message(' ', 'text/plain') else: # Try to wakeup XMPP clients sender = self.sip_identity tmp = self.sip_session.local_identity.uri recipient_uri = FrozenURI(tmp.user, tmp.host) recipient = Identity(recipient_uri) xmpp_manager = XMPPManager() xmpp_manager.send_stanza(ChatMessage(sender, recipient, ' ', 'text/plain')) # Send queued messages self._send_queued_messages() def _NH_SIPSessionDidEnd(self, notification): log.info("SIP session %s ended" % self.sip_session.call_id) notification.center.remove_observer(self, sender=self.sip_session) notification.center.remove_observer(self, sender=self.msrp_stream) self.sip_session = None self.msrp_stream = None self.end() def _NH_SIPSessionDidFail(self, notification): log.info("SIP session %s failed" % self.sip_session.call_id) notification.center.remove_observer(self, sender=self.sip_session) notification.center.remove_observer(self, sender=self.msrp_stream) self.sip_session = None self.msrp_stream = None self.end() def _NH_SIPSessionNewProposal(self, notification): if notification.data.originator == 'remote': self.sip_session.reject_proposal() def _NH_SIPSessionTransferNewIncoming(self, notification): self.sip_session.reject_transfer(403) def _NH_ChatStreamGotMessage(self, notification): # Notification is sent by the MSRP stream message = notification.data.message content_type = message.content_type.lower() if content_type not in ('text/plain', 'text/html'): return if content_type == 'text/plain': html_body = None body = message.content else: html_body = message.content body = None if self._sip_session_timer is not None and self._sip_session_timer.active(): self._sip_session_timer.reset(SESSION_TIMEOUT) chunk = notification.data.chunk if self.started: self.xmpp_session.send_message(body, html_body, message_id=chunk.message_id) if self.use_receipts: self._pending_msrp_chunks[chunk.message_id] = chunk else: self.msrp_stream.msrp_session.send_report(chunk, 200, 'OK') else: sender = self.sip_identity recipient_uri = FrozenURI.parse(message.recipients[0].uri) recipient = Identity(recipient_uri, message.recipients[0].display_name) xmpp_manager = XMPPManager() xmpp_manager.send_stanza(ChatMessage(sender, recipient, body, html_body)) self.msrp_stream.msrp_session.send_report(chunk, 200, 'OK') def _NH_ChatStreamGotComposingIndication(self, notification): # Notification is sent by the MSRP stream if self._sip_session_timer is not None and self._sip_session_timer.active(): self._sip_session_timer.reset(SESSION_TIMEOUT) if not self.started: return state = None if notification.data.state == 'active': state = 'composing' elif notification.data.state == 'idle': state = 'paused' if state is not None: self.xmpp_session.send_composing_indication(state) def _NH_ChatStreamDidDeliverMessage(self, notification): if self.started: message = self._pending_xmpp_stanzas.pop(notification.data.message_id, None) if message is not None: self.xmpp_session.send_receipt_acknowledgement(message.id) def _NH_ChatStreamDidNotDeliverMessage(self, notification): if self.started: message = self._pending_xmpp_stanzas.pop(notification.data.message_id, None) if message is not None: self.xmpp_session.send_error(message, 'TODO', []) # TODO def _NH_XMPPChatSessionDidStart(self, notification): if self.sip_session is not None: # Session is now established on both ends self.started = True def _NH_XMPPChatSessionDidEnd(self, notification): notification.center.remove_observer(self, sender=self.xmpp_session) self.xmpp_session = None self.end() def _NH_XMPPChatSessionGotMessage(self, notification): if self.sip_session is None or self.sip_session.state != 'connected': self._xmpp_message_queue.append(notification.data.message) return if self._sip_session_timer is not None and self._sip_session_timer.active(): self._sip_session_timer.reset(SESSION_TIMEOUT) message = notification.data.message sender_uri = message.sender.uri.as_sip_uri() del sender_uri.parameters['gr'] # no GRUU in CPIM From header sender = ChatIdentity(sender_uri) self.use_receipts = message.use_receipt if not message.use_receipt: notify_progress = False else: notify_progress = True self._pending_xmpp_stanzas[message.id] = message # Prefer plaintext self.msrp_stream.send_message(message.body, 'text/plain', sender=sender, message_id=str(message.id), notify_progress=notify_progress) self.msrp_stream.send_composing_indication('idle', 30, sender=sender) def _NH_XMPPChatSessionGotComposingIndication(self, notification): if self.sip_session is None or self.sip_session.state != 'connected': return if self._sip_session_timer is not None and self._sip_session_timer.active(): self._sip_session_timer.reset(SESSION_TIMEOUT) message = notification.data.message state = None if message.state == 'composing': state = 'active' elif message.state == 'paused': state = 'idle' if state is not None: sender_uri = message.sender.uri.as_sip_uri() del sender_uri.parameters['gr'] # no GRUU in CPIM From header sender = ChatIdentity(sender_uri) self.msrp_stream.send_composing_indication(state, 30, sender=sender) if message.use_receipt: self.xmpp_session.send_receipt_acknowledgement(message.id) def _NH_XMPPChatSessionDidDeliverMessage(self, notification): chunk = self._pending_msrp_chunks.pop(notification.data.message_id, None) if chunk is not None: self.msrp_stream.msrp_session.send_report(chunk, 200, 'OK') def _NH_XMPPChatSessionDidNotDeliverMessage(self, notification): chunk = self._pending_msrp_chunks.pop(notification.data.message_id, None) if chunk is not None: self.msrp_stream.msrp_session.send_report(chunk, notification.data.code, notification.data.reason) def chunks(text, size): - for i in xrange(0, len(text), size): + for i in range(0, len(text), size): yield text[i:i+size] class SIPMessageError(Exception): def __init__(self, code, reason): Exception.__init__(self, reason) self.code = code self.reason = reason +@implementer(IObserver) class SIPMessageSender(object): - implements(IObserver) def __init__(self, message, content_type=None, use_cpim=False): # TODO: sometimes we may want to send it to the GRUU, for example when a XMPP client # replies to one of our messages. MESSAGE requests don't need a Contact header, though # so how should we communicate our GRUU to the recipient? self.from_uri = message.sender.uri.as_sip_uri() self.from_uri.parameters.pop('gr', None) # No GRUU in From header self.to_uri = message.recipient.uri.as_sip_uri() self.to_uri.parameters.pop('gr', None) # Don't send it to the GRUU self.body = message.html_body or message.body self.content_type = content_type if content_type else ('text/html' if message.html_body else 'text/plain') self._requests = set() self._channel = coros.queue() self.use_cpim = use_cpim @run_in_waitable_green_thread def send(self): lookup = DNSLookup() settings = SIPSimpleSettings() account = DefaultAccount() if account.sip.outbound_proxy is not None: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) else: uri = self.to_uri try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError: msg = 'DNS lookup error while looking for %s proxy' % uri log.warning(msg) raise SIPMessageError(0, msg) else: route = routes.pop(0) from_header = FromHeader(self.from_uri) to_header = ToHeader(self.to_uri) route_header = RouteHeader(route.uri) notification_center = NotificationCenter() for chunk in chunks(self.body, 1000): if self.use_cpim: additional_headers = [] payload = CPIMPayload(self.body.encode('utf-8'), self.content_type, charset='utf-8', sender=ChatIdentity(self.from_uri, None), recipients=[ChatIdentity(self.to_uri, None)], additional_headers=additional_headers) payload, content_type = payload.encode() else: content_type = self.content_type payload = self.body.encode('utf-8') request = SIPMessageRequest(from_header, to_header, route_header, content_type, payload) notification_center.add_observer(self, sender=request) self._requests.add(request) request.send() error = None count = len(self._requests) while count > 0: notification = self._channel.wait() if notification.name == 'SIPMessageDidFail': error = (notification.data.code, notification.data.reason) count -= 1 self._requests.clear() if error is not None: raise SIPMessageError(*error) @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPMessageDidSucceed(self, notification): notification.center.remove_observer(self, sender=notification.sender) self._channel.send(notification) def _NH_SIPMessageDidFail(self, notification): notification.center.remove_observer(self, sender=notification.sender) self._channel.send(notification) diff --git a/sylk/applications/xmppgateway/media.py b/sylk/applications/xmppgateway/media.py index 3d9735b..aad1aa7 100644 --- a/sylk/applications/xmppgateway/media.py +++ b/sylk/applications/xmppgateway/media.py @@ -1,327 +1,327 @@ from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from eventlib.twistedutil import block_on from sipsimple.audio import AudioConference from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import FromHeader, ToHeader from sipsimple.core import SIPURI, SIPCoreError from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.streams import MediaStreamRegistry as SIPMediaStreamRegistry from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import run_in_green_thread -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.applications.xmppgateway.datatypes import Identity, FrozenURI, generate_sylk_resource, decode_resource from sylk.applications.xmppgateway.logger import log from sylk.applications.xmppgateway.xmpp import XMPPManager from sylk.applications.xmppgateway.xmpp.jingle.session import JingleSession from sylk.applications.xmppgateway.xmpp.jingle.streams import MediaStreamRegistry as JingleMediaStreamRegistry from sylk.applications.xmppgateway.xmpp.stanzas import jingle from sylk.session import Session __all__ = 'MediaSessionHandler', +@implementer(IObserver) class MediaSessionHandler(object): - implements(IObserver) def __init__(self): self.started = False self.ended = False self._sip_identity = None self._xmpp_identity = None self._audio_bidge = AudioConference() self.sip_session = None self.jingle_session = None @classmethod def new_from_sip_session(cls, session): proposed_stream_types = set(stream.type for stream in session.proposed_streams) streams = [] for stream_type in proposed_stream_types: try: klass = JingleMediaStreamRegistry.get(stream_type) except Exception: continue streams.append(klass()) if not streams: session.reject(488) return None session.send_ring_indication() instance = cls() NotificationCenter().add_observer(instance, sender=session) # Get URI representing the SIP side contact_uri = session._invitation.remote_contact_header.uri if contact_uri.parameters.get('gr') is not None: sip_leg_uri = FrozenURI(contact_uri.user, contact_uri.host, contact_uri.parameters.get('gr')) else: tmp = session.remote_identity.uri sip_leg_uri = FrozenURI(tmp.user, tmp.host, generate_sylk_resource()) instance._sip_identity = Identity(sip_leg_uri) # Get URI representing the XMPP side request_uri = session.request_uri remote_resource = request_uri.parameters.get('gr', None) if remote_resource is not None: try: remote_resource = decode_resource(remote_resource) except (TypeError, UnicodeError): remote_resource = None xmpp_leg_uri = FrozenURI(request_uri.user, request_uri.host, remote_resource) instance._xmpp_identity = Identity(xmpp_leg_uri) instance.sip_session = session instance._start_outgoing_jingle_session(streams) return instance @classmethod def new_from_jingle_session(cls, session): proposed_stream_types = set(stream.type for stream in session.proposed_streams) streams = [] for stream_type in proposed_stream_types: try: klass = SIPMediaStreamRegistry.get(stream_type) except Exception: continue streams.append(klass()) if not streams: session.reject('unsupported-applications') return None session.send_ring_indication() instance = cls() NotificationCenter().add_observer(instance, sender=session) instance._xmpp_identity = session.remote_identity instance._sip_identity = session.local_identity instance.jingle_session = session instance._start_outgoing_sip_session(streams) return instance @property def sip_identity(self): return self._sip_identity @property def xmpp_identity(self): return self._xmpp_identity @property def started(self): return self.__dict__['started'] @started.setter def started(self, value): old_value = self.__dict__.get('started', False) self.__dict__['started'] = value if not old_value and value: NotificationCenter().post_notification('MediaSessionHandlerDidStart', sender=self) @run_in_green_thread def _start_outgoing_sip_session(self, streams): notification_center = NotificationCenter() # self.xmpp_identity is our local identity on the SIP side from_uri = self.xmpp_identity.uri.as_sip_uri() from_uri.parameters.pop('gr', None) # no GRUU in From header to_uri = self.sip_identity.uri.as_sip_uri() to_uri.parameters.pop('gr', None) # no GRUU in To header # TODO: need to fix GRUU in the proxy #contact_uri = self.xmpp_identity.uri.as_sip_uri() #contact_uri.parameters['gr'] = encode_resource(contact_uri.parameters['gr'].decode('utf-8')) lookup = DNSLookup() settings = SIPSimpleSettings() account = DefaultAccount() if account.sip.outbound_proxy is not None: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) else: uri = to_uri try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError: log.warning('DNS lookup error while looking for %s proxy' % uri) notification_center.post_notification('MedialSessionHandlerDidFail', sender=self, data=NotificationData(reason='DNS lookup error')) return route = routes.pop(0) from_header = FromHeader(from_uri) to_header = ToHeader(to_uri) self.sip_session = Session(account) notification_center.add_observer(self, sender=self.sip_session) self.sip_session.connect(from_header, to_header, route=route, streams=streams) @run_in_green_thread def _start_outgoing_jingle_session(self, streams): if self.xmpp_identity.uri.resource is not None: self.sip_session.reject() return xmpp_manager = XMPPManager() local_jid = self.sip_identity.uri.as_xmpp_jid() remote_jid = self.xmpp_identity.uri.as_xmpp_jid() # If this was an invitation to a conference, use the information in the Referred-By header if self.sip_identity.uri.host in xmpp_manager.muc_domains and self.sip_session.transfer_info and self.sip_session.transfer_info.referred_by: try: referred_by_uri = SIPURI.parse(self.sip_session.transfer_info.referred_by) except SIPCoreError: self.sip_session.reject(488) return else: inviter_uri = FrozenURI(referred_by_uri.user, referred_by_uri.host) local_jid = inviter_uri.as_xmpp_jid() # Use disco to gather potential JIDs to call d = xmpp_manager.disco_client_protocol.requestItems(remote_jid, sender=local_jid) try: items = block_on(d) except Exception: items = [] if not items: self.sip_session.reject(480) return # Check which items support Jingle valid = [] for item in items: d = xmpp_manager.disco_client_protocol.requestInfo(item.entity, nodeIdentifier=item.nodeIdentifier, sender=local_jid) try: info = block_on(d) except Exception: continue if jingle.NS_JINGLE in info.features and jingle.NS_JINGLE_APPS_RTP in info.features: valid.append(item.entity) if not valid: self.sip_session.reject(480) return # TODO: start multiple sessions? self._xmpp_identity = Identity(FrozenURI.parse(valid[0])) notification_center = NotificationCenter() if self.sip_identity.uri.host in xmpp_manager.muc_domains: self.jingle_session = JingleSession(xmpp_manager.jingle_coin_protocol) else: self.jingle_session = JingleSession(xmpp_manager.jingle_protocol) notification_center.add_observer(self, sender=self.jingle_session) self.jingle_session.connect(self.sip_identity, self.xmpp_identity, streams, is_focus=self.sip_session.remote_focus) def end(self): if self.ended: return notification_center = NotificationCenter() if self.sip_session is not None: notification_center.remove_observer(self, sender=self.sip_session) if self.sip_session.direction == 'incoming' and not self.started: self.sip_session.reject() else: self.sip_session.end() self.sip_session = None if self.jingle_session is not None: notification_center.remove_observer(self, sender=self.jingle_session) if self.jingle_session.direction == 'incoming' and not self.started: self.jingle_session.reject() else: self.jingle_session.end() self.jingle_session = None self.ended = True if self.started: notification_center.post_notification('MediaSessionHandlerDidEnd', sender=self) else: notification_center.post_notification('MediaSessionHandlerDidFail', sender=self) @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidStart(self, notification): log.info("SIP session %s started" % self.sip_session.call_id) if self.sip_session.direction == 'outgoing': # Time to accept the Jingle session and bridge them together try: audio_stream = next(stream for stream in self.sip_session.streams if stream.type=='audio') except StopIteration: pass else: self._audio_bidge.add(audio_stream) self.jingle_session.accept(self.jingle_session.proposed_streams, is_focus=self.sip_session.remote_focus) else: # Both sessions have been accepted now self.started = True try: audio_stream = next(stream for stream in self.sip_session.streams if stream.type=='audio') except StopIteration: pass else: self._audio_bidge.add(audio_stream) def _NH_SIPSessionDidEnd(self, notification): log.info("SIP session %s ended" % self.sip_session.call_id) notification.center.remove_observer(self, sender=self.sip_session) self.sip_session = None self.end() def _NH_SIPSessionDidFail(self, notification): log.info("SIP session %s failed (%s)" % (self.sip_session.call_id, notification.data.reason)) notification.center.remove_observer(self, sender=self.sip_session) self.sip_session = None self.end() def _NH_SIPSessionNewProposal(self, notification): if notification.data.originator == 'remote': self.sip_session.reject_proposal() def _NH_SIPSessionTransferNewIncoming(self, notification): self.sip_session.reject_transfer(403) def _NH_SIPSessionDidChangeHoldState(self, notification): if notification.data.originator == 'remote': if notification.data.on_hold: self.jingle_session.hold() else: self.jingle_session.unhold() def _NH_SIPSessionGotConferenceInfo(self, notification): self.jingle_session._send_conference_info(notification.data.conference_info.toxml()) def _NH_JingleSessionDidStart(self, notification): log.info("Jingle session %s started" % notification.sender.id) if self.jingle_session.direction == 'incoming': # Both sessions have been accepted now self.started = True try: audio_stream = next(stream for stream in self.jingle_session.streams if stream.type=='audio') except StopIteration: pass else: self._audio_bidge.add(audio_stream) else: # Time to accept the Jingle session and bridge them together try: audio_stream = next(stream for stream in self.jingle_session.streams if stream.type=='audio') except StopIteration: pass else: self._audio_bidge.add(audio_stream) self.sip_session.accept(self.sip_session.proposed_streams) def _NH_JingleSessionDidEnd(self, notification): log.info("Jingle session %s ended" % notification.sender.id) notification.center.remove_observer(self, sender=self.jingle_session) self.jingle_session = None self.end() def _NH_JingleSessionDidFail(self, notification): log.info("Jingle session %s failed (%s)" % (notification.sender.id, notification.data.reason)) notification.center.remove_observer(self, sender=self.jingle_session) self.jingle_session = None self.end() def _NH_JingleSessionDidChangeHoldState(self, notification): if notification.data.originator == 'remote': if notification.data.on_hold: self.sip_session.hold() else: self.sip_session.unhold() diff --git a/sylk/applications/xmppgateway/muc.py b/sylk/applications/xmppgateway/muc.py index 18eb99e..3847c70 100644 --- a/sylk/applications/xmppgateway/muc.py +++ b/sylk/applications/xmppgateway/muc.py @@ -1,475 +1,475 @@ import uuid from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null, limit from application.python.descriptor import WriteOnceAttribute from eventlib import coros, proc from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import Engine, SIPURI, SIPCoreError, Referral, sip_status_messages from sipsimple.core import ContactHeader, FromHeader, ToHeader, ReferToHeader, RouteHeader from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.streams import MediaStreamRegistry from sipsimple.streams.msrp.chat import ChatStreamError, ChatIdentity from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import run_in_green_thread from time import time from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.applications.xmppgateway.datatypes import Identity, FrozenURI, encode_resource from sylk.applications.xmppgateway.logger import log from sylk.applications.xmppgateway.xmpp import XMPPManager from sylk.applications.xmppgateway.xmpp.session import XMPPIncomingMucSession from sylk.applications.xmppgateway.xmpp.stanzas import MUCAvailabilityPresence, MUCErrorPresence, OutgoingInvitationMessage, STANZAS_NS from sylk.configuration import SIPConfig from sylk.session import Session class ReferralError(Exception): def __init__(self, error, code=0): self.error = error self.code = code class SIPReferralDidFail(Exception): def __init__(self, data): self.data = data class MucInvitationFailure(object): def __init__(self, code, reason): self.code = code self.reason = reason def __str__(self): return '%s (%s)' % (self.code, self.reason) +@implementer(IObserver) class X2SMucInvitationHandler(object): - implements(IObserver) def __init__(self, sender, recipient, participant): self.sender = sender self.recipient = recipient self.participant = participant self.active = False self.route = None self._channel = coros.queue() self._referral = None self._failure = None def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, name='NetworkConditionsDidChange') proc.spawn(self._run) notification_center.post_notification('X2SMucInvitationHandlerDidStart', sender=self) def _run(self): notification_center = NotificationCenter() settings = SIPSimpleSettings() sender_uri = self.sender.uri.as_sip_uri() recipient_uri = self.recipient.uri.as_sip_uri() participant_uri = self.participant.uri.as_sip_uri() try: # Lookup routes account = DefaultAccount() if account.sip.outbound_proxy is not None and account.sip.outbound_proxy.transport in settings.sip.transport_list: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) elif account.sip.always_use_my_proxy: uri = SIPURI(host=account.id.domain) else: uri = SIPURI.new(recipient_uri) lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError as e: raise ReferralError(error='DNS lookup failed: %s' % e) timeout = time() + 30 for route in routes: self.route = route remaining_time = timeout - time() if remaining_time > 0: transport = route.transport parameters = {} if transport=='udp' else {'transport': transport} contact_uri = SIPURI(user=account.contact.username, host=SIPConfig.local_ip.normalized, port=getattr(Engine(), '%s_port' % transport), parameters=parameters) refer_to_header = ReferToHeader(str(participant_uri)) refer_to_header.parameters['method'] = 'INVITE' referral = Referral(recipient_uri, FromHeader(sender_uri), ToHeader(recipient_uri), refer_to_header, ContactHeader(contact_uri), RouteHeader(route.uri), account.credentials) notification_center.add_observer(self, sender=referral) try: referral.send_refer(timeout=limit(remaining_time, min=1, max=5)) except SIPCoreError: notification_center.remove_observer(self, sender=referral) timeout = 5 raise ReferralError(error='Internal error') self._referral = referral try: while True: notification = self._channel.wait() if notification.name == 'SIPReferralDidStart': break except SIPReferralDidFail as e: notification_center.remove_observer(self, sender=referral) self._referral = None if e.data.code in (403, 405): raise ReferralError(error=sip_status_messages[e.data.code], code=e.data.code) else: # Otherwise just try the next route continue else: break else: self.route = None raise ReferralError(error='No more routes to try') # At this point it is subscribed. Handle notifications and ending/failures. try: self.active = True while True: notification = self._channel.wait() if notification.name == 'SIPReferralDidEnd': break except SIPReferralDidFail as e: notification_center.remove_observer(self, sender=self._referral) raise ReferralError(error=e.data.reason, code=e.data.code) else: notification_center.remove_observer(self, sender=self._referral) finally: self.active = False except ReferralError as e: self._failure = MucInvitationFailure(e.code, e.error) finally: notification_center.remove_observer(self, name='NetworkConditionsDidChange') self._referral = None if self._failure is not None: notification_center.post_notification('X2SMucInvitationHandlerDidFail', sender=self, data=NotificationData(failure=self._failure)) else: notification_center.post_notification('X2SMucInvitationHandlerDidEnd', sender=self) def _refresh(self): account = DefaultAccount() transport = self.route.transport parameters = {} if transport=='udp' else {'transport': transport} contact_uri = SIPURI(user=account.contact.username, host=SIPConfig.local_ip.normalized, port=getattr(Engine(), '%s_port' % transport), parameters=parameters) contact_header = ContactHeader(contact_uri) self._referral.refresh(contact_header=contact_header, timeout=2) @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPReferralDidStart(self, notification): self._channel.send(notification) def _NH_SIPReferralDidEnd(self, notification): self._channel.send(notification) def _NH_SIPReferralDidFail(self, notification): self._channel.send_exception(SIPReferralDidFail(notification.data)) def _NH_SIPReferralGotNotify(self, notification): self._channel.send(notification) def _NH_NetworkConditionsDidChange(self, notification): if self.active: self._refresh() +@implementer(IObserver) class S2XMucInvitationHandler(object): - implements(IObserver) def __init__(self, session, sender, recipient, inviter): self.session = session self.sender = sender self.recipient = recipient self.inviter = inviter self._timer = None self._failure = None def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) stanza = OutgoingInvitationMessage(self.sender, self.recipient, self.inviter, id='MUC.'+uuid.uuid4().hex) xmpp_manager = XMPPManager() xmpp_manager.send_muc_stanza(stanza) self._timer = reactor.callLater(90, self._timeout) notification_center.post_notification('S2XMucInvitationHandlerDidStart', sender=self) def stop(self): if self._timer is not None and self._timer.active(): self._timer.cancel() self._timer = None notification_center = NotificationCenter() if self.session is not None: notification_center.remove_observer(self, sender=self.session) reactor.callLater(5, self._end_session, self.session) self.session = None if self._failure is not None: notification_center.post_notification('S2XMucInvitationHandlerDidFail', sender=self, data=NotificationData(failure=self._failure)) else: notification_center.post_notification('S2XMucInvitationHandlerDidEnd', sender=self) def _end_session(self, session): try: session.end(480) except Exception: pass def _timeout(self): NotificationCenter().remove_observer(self, sender=self.session) try: self.session.end(408) except Exception: pass self.session = None self._failure = MucInvitationFailure('Timeout', 408) self.stop() def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidFail(self, notification): notification.center.remove_observer(self, sender=self.session) self.session = None self._failure = MucInvitationFailure(notification.data.reason or notification.data.failure_reason, notification.data.code) self.stop() +@implementer(IObserver) class X2SMucHandler(object): - implements(IObserver) sip_identity = WriteOnceAttribute() xmpp_identity = WriteOnceAttribute() def __init__(self, sip_identity, xmpp_identity, nickname): self.sip_identity = sip_identity self.xmpp_identity = xmpp_identity self.nickname = nickname self._xmpp_muc_session = None self._sip_session = None self._msrp_stream = None self._first_stanza = None self._pending_nicknames_map = {} # map message ID of MSRP NICKNAME chunk to corresponding stanza self._pending_messages_map = {} # map message ID of MSRP SEND chunk to corresponding stanza self._participants = set() # set of (URI, nickname) tuples self.ended = False def start(self): notification_center = NotificationCenter() self._xmpp_muc_session = XMPPIncomingMucSession(local_identity=self.sip_identity, remote_identity=self.xmpp_identity) notification_center.add_observer(self, sender=self._xmpp_muc_session) self._xmpp_muc_session.start() notification_center.post_notification('X2SMucHandlerDidStart', sender=self) self._start_sip_session() def end(self): if self.ended: return notification_center = NotificationCenter() if self._xmpp_muc_session is not None: notification_center.remove_observer(self, sender=self._xmpp_muc_session) # Send indication that the user has been kicked from the room sender = Identity(FrozenURI(self.sip_identity.uri.user, self.sip_identity.uri.host, self.nickname)) stanza = MUCAvailabilityPresence(sender, self.xmpp_identity, available=False) stanza.jid = self.xmpp_identity stanza.muc_statuses.append('307') xmpp_manager = XMPPManager() xmpp_manager.send_muc_stanza(stanza) self._xmpp_muc_session.end() self._xmpp_muc_session = None if self._sip_session is not None: notification_center.remove_observer(self, sender=self._sip_session) self._sip_session.end() self._sip_session = None self.ended = True notification_center.post_notification('X2SMucHandlerDidEnd', sender=self) @run_in_green_thread def _start_sip_session(self): # self.xmpp_identity is our local identity from_uri = self.xmpp_identity.uri.as_sip_uri() del from_uri.parameters['gr'] # no GRUU in From header contact_uri = self.xmpp_identity.uri.as_sip_uri() contact_uri.parameters['gr'] = encode_resource(contact_uri.parameters['gr'].decode('utf-8')) to_uri = self.sip_identity.uri.as_sip_uri() lookup = DNSLookup() settings = SIPSimpleSettings() account = DefaultAccount() if account.sip.outbound_proxy is not None: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) else: uri = to_uri try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError: log.warning('DNS lookup error while looking for %s proxy' % uri) self.end() return self._msrp_stream = MediaStreamRegistry.get('chat')() route = routes.pop(0) from_header = FromHeader(from_uri) to_header = ToHeader(to_uri) contact_header = ContactHeader(contact_uri) self._sip_session = Session(account) notification_center = NotificationCenter() notification_center.add_observer(self, sender=self._sip_session) notification_center.add_observer(self, sender=self._msrp_stream) self._sip_session.connect(from_header, to_header, contact_header=contact_header, route=route, streams=[self._msrp_stream]) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidStart(self, notification): log.info("SIP multiparty session %s started" % self._sip_session.call_id) if not self._sip_session.remote_focus or not self._msrp_stream.nickname_allowed: self.end() return message_id = self._msrp_stream.set_local_nickname(self.nickname) self._pending_nicknames_map[message_id] = (self.nickname, self._first_stanza) self._first_stanza = None def _NH_SIPSessionDidEnd(self, notification): log.info("SIP multiparty session %s ended" % self._sip_session.call_id) notification.center.remove_observer(self, sender=self._sip_session) notification.center.remove_observer(self, sender=self._msrp_stream) self._sip_session = None self._msrp_stream = None self.end() def _NH_SIPSessionDidFail(self, notification): log.info("SIP multiparty session %s failed" % self._sip_session.call_id) notification.center.remove_observer(self, sender=self._sip_session) notification.center.remove_observer(self, sender=self._msrp_stream) self._sip_session = None self._msrp_stream = None self.end() def _NH_SIPSessionNewProposal(self, notification): if notification.data.originator == 'remote': self._sip_session.reject_proposal() def _NH_SIPSessionTransferNewIncoming(self, notification): self._sip_session.reject_transfer(403) def _NH_SIPSessionGotConferenceInfo(self, notification): # Translate to XMPP payload xmpp_manager = XMPPManager() own_uri = FrozenURI(self.xmpp_identity.uri.user, self.xmpp_identity.uri.host) conference_info = notification.data.conference_info new_participants = set() for user in conference_info.users: user_uri = FrozenURI.parse(user.entity if user.entity.startswith(('sip:', 'sips:')) else 'sip:'+user.entity) nickname = user.display_text.value if user.display_text else user.entity new_participants.add((user_uri, nickname)) # Remove participants that are no longer in the room for uri, nickname in self._participants - new_participants: sender = Identity(FrozenURI(self.sip_identity.uri.user, self.sip_identity.uri.host, nickname)) stanza = MUCAvailabilityPresence(sender, self.xmpp_identity, available=False) xmpp_manager.send_muc_stanza(stanza) # Send presence for current participants for uri, nickname in new_participants: if uri == own_uri: continue sender = Identity(FrozenURI(self.sip_identity.uri.user, self.sip_identity.uri.host, nickname)) stanza = MUCAvailabilityPresence(sender, self.xmpp_identity, available=True) stanza.jid = Identity(uri) xmpp_manager.send_muc_stanza(stanza) self._participants = new_participants # Send own status last sender = Identity(FrozenURI(self.sip_identity.uri.user, self.sip_identity.uri.host, self.nickname)) stanza = MUCAvailabilityPresence(sender, self.xmpp_identity, available=True) stanza.jid = self.xmpp_identity stanza.muc_statuses.append('110') xmpp_manager.send_muc_stanza(stanza) def _NH_ChatStreamGotMessage(self, notification): # Notification is sent by the MSRP stream if not self._xmpp_muc_session: return message = notification.data.message content_type = message.content_type.lower() if content_type not in ('text/plain', 'text/html'): return if content_type == 'text/plain': html_body = None body = message.content else: html_body = message.content body = None resource = message.sender.display_name or str(message.sender.uri) sender = Identity(FrozenURI(self.sip_identity.uri.user, self.sip_identity.uri.host, resource)) self._xmpp_muc_session.send_message(sender, body, html_body, message_id='MUC.'+uuid.uuid4().hex) self._msrp_stream.msrp_session.send_report(notification.data.chunk, 200, 'OK') def _NH_ChatStreamDidSetNickname(self, notification): # Notification is sent by the MSRP stream nickname, stanza = self._pending_nicknames_map.pop(notification.data.message_id) self.nickname = nickname def _NH_ChatStreamDidNotSetNickname(self, notification): # Notification is sent by the MSRP stream nickname, stanza = self._pending_nicknames_map.pop(notification.data.message_id) error_stanza = MUCErrorPresence.from_stanza(stanza, 'cancel', [('conflict', STANZAS_NS)]) xmpp_manager = XMPPManager() xmpp_manager.send_muc_stanza(error_stanza) def _NH_ChatStreamDidDeliverMessage(self, notification): # Echo back the message to the sender stanza = self._pending_messages_map.pop(notification.data.message_id) stanza.sender, stanza.recipient = stanza.recipient, stanza.sender stanza.sender.uri = FrozenURI(stanza.sender.uri.user, stanza.sender.uri.host, self.nickname) xmpp_manager = XMPPManager() xmpp_manager.send_muc_stanza(stanza) def _NH_ChatStreamDidNotDeliverMessage(self, notification): self._pending_messages_map.pop(notification.data.message_id) def _NH_XMPPIncomingMucSessionDidEnd(self, notification): notification.center.remove_observer(self, sender=self._xmpp_muc_session) self._xmpp_muc_session = None self.end() def _NH_XMPPIncomingMucSessionGotMessage(self, notification): if not self._sip_session: return message = notification.data.message sender_uri = message.sender.uri.as_sip_uri() del sender_uri.parameters['gr'] # no GRUU in CPIM From header sender = ChatIdentity(sender_uri, display_name=self.nickname) message_id = self._msrp_stream.send_message(message.body, 'text/plain', sender=sender) self._pending_messages_map[message_id] = message # Message will be echoed back to the sender on ChatStreamDidDeliverMessage def _NH_XMPPIncomingMucSessionChangedNickname(self, notification): if not self._sip_session: return nickname = notification.data.nickname try: message_id = self._msrp_stream.set_local_nickname(nickname) except ChatStreamError: return self._pending_nicknames_map[message_id] = (nickname, notification.data.stanza) def _NH_XMPPIncomingMucSessionSubject(self, notification): if not self._sip_session: return message = notification.data.message sender_uri = message.sender.uri.as_sip_uri() del sender_uri.parameters['gr'] # no GRUU in CPIM From header sender = ChatIdentity(sender_uri, display_name=self.nickname) message_id = self._msrp_stream.send_message('Conference title set to: %s' % message.body, 'text/plain', sender=sender) self._pending_messages_map[message_id] = message diff --git a/sylk/applications/xmppgateway/presence.py b/sylk/applications/xmppgateway/presence.py index 68e58a1..3a3954c 100644 --- a/sylk/applications/xmppgateway/presence.py +++ b/sylk/applications/xmppgateway/presence.py @@ -1,521 +1,521 @@ import hashlib import random from application.notification import IObserver, NotificationCenter from application.python import Null, limit from application.python.descriptor import WriteOnceAttribute from eventlib import coros, proc from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import Engine, SIPURI, SIPCoreError from sipsimple.core import ContactHeader, FromHeader, RouteHeader, ToHeader from sipsimple.core import Subscription from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.payloads import pidf, rpid, caps from sipsimple.payloads import ParserError from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, run_in_green_thread from sipsimple.util import ISOTimestamp from time import time from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.applications.xmppgateway.configuration import XMPPGatewayConfig from sylk.applications.xmppgateway.datatypes import Identity, FrozenURI, encode_resource from sylk.applications.xmppgateway.logger import log from sylk.applications.xmppgateway.util import format_uri from sylk.applications.xmppgateway.xmpp.stanzas import AvailabilityPresence from sylk.applications.xmppgateway.xmpp.subscription import XMPPSubscription, XMPPIncomingSubscription from sylk.configuration import SIPConfig __all__ = 'S2XPresenceHandler', 'X2SPresenceHandler' +@implementer(IObserver) class S2XPresenceHandler(object): - implements(IObserver) sip_identity = WriteOnceAttribute() xmpp_identity = WriteOnceAttribute() def __init__(self, sip_identity, xmpp_identity): self.ended = False self._sip_subscriptions = [] self._stanza_cache = {} self._pidf = None self._xmpp_subscription = None self.sip_identity = sip_identity self.xmpp_identity = xmpp_identity def start(self): notification_center = NotificationCenter() self._xmpp_subscription = XMPPSubscription(local_identity=self.sip_identity, remote_identity=self.xmpp_identity) notification_center.add_observer(self, sender=self._xmpp_subscription) self._xmpp_subscription.start() notification_center.post_notification('S2XPresenceHandlerDidStart', sender=self) def end(self): if self.ended: return notification_center = NotificationCenter() if self._xmpp_subscription is not None: notification_center.remove_observer(self, sender=self._xmpp_subscription) self._xmpp_subscription.end() self._xmpp_subscription = None while self._sip_subscriptions: subscription = self._sip_subscriptions.pop() notification_center.remove_observer(self, sender=subscription) try: subscription.end() except SIPCoreError: pass self.ended = True notification_center.post_notification('S2XPresenceHandlerDidEnd', sender=self) def add_sip_subscription(self, subscription): # If s subscription is received after the handle has ended but before # S2XPresenceHandlerDidEnd has been processed we need to ignore it and wait for a retransmission # which we will handle by creating a new S2XPresenceHandler if self.ended: return self._sip_subscriptions.append(subscription) NotificationCenter().add_observer(self, sender=subscription) if self._xmpp_subscription.state == 'active': pidf_doc = self._pidf content_type = pidf.PIDFDocument.content_type if pidf_doc is not None else None try: subscription.accept(content_type, pidf_doc) except SIPCoreError as e: log.warning('Error accepting SIP subscription: %s' % e) subscription.end() else: try: subscription.accept_pending() except SIPCoreError as e: log.warning('Error accepting SIP subscription: %s' % e) subscription.end() if XMPPGatewayConfig.log_presence: log.info('SIP subscription from %s to %s added to presence flow 0x%x (%d subs)' % (format_uri(self.sip_identity.uri, 'sip'), format_uri(self.xmpp_identity.uri, 'xmpp'), id(self), len(self._sip_subscriptions))) def _build_pidf(self): if not self._stanza_cache: self._pidf = None return None pidf_doc = pidf.PIDF(str(self.xmpp_identity)) - uri = next(self._stanza_cache.iterkeys()) + uri = next(iter(self._stanza_cache.keys())) person = pidf.Person("PID-%s" % hashlib.md5("%s@%s" % (uri.user, uri.host)).hexdigest()) person.activities = rpid.Activities() pidf_doc.add(person) - for stanza in self._stanza_cache.itervalues(): + for stanza in self._stanza_cache.values(): if not stanza.available: status = pidf.Status('closed') status.extended = 'offline' else: status = pidf.Status('open') if stanza.show == 'away': status.extended = 'away' if 'away' not in person.activities: person.activities.add('away') elif stanza.show == 'xa': status.extended = 'away' if 'away' not in person.activities: person.activities.add('away') elif stanza.show == 'dnd': status.extended = 'busy' if 'busy' not in person.activities: person.activities.add('busy') else: status.extended = 'available' if stanza.sender.uri.resource: resource = encode_resource(stanza.sender.uri.resource) else: # Workaround for clients not sending the resource under certain (unknown) circumstances resource = hashlib.md5("%s@%s" % (uri.user, uri.host)).hexdigest() service_id = "SID-%s" % resource sip_uri = stanza.sender.uri.as_sip_uri() sip_uri.parameters['gr'] = resource sip_uri.parameters['xmpp'] = None contact = pidf.Contact(str(sip_uri)) service = pidf.Service(service_id, status=status, contact=contact) service.add(pidf.DeviceID(resource)) service.device_info = pidf.DeviceInfo(resource, description=stanza.sender.uri.resource) service.timestamp = pidf.ServiceTimestamp(stanza.timestamp) service.capabilities = caps.ServiceCapabilities(text=True, message=True) - for lang, note in stanza.statuses.iteritems(): + for lang, note in stanza.statuses.items(): service.notes.add(pidf.PIDFNote(note, lang=lang)) pidf_doc.add(service) if not person.activities: person.activities = None self._pidf = pidf_doc.toxml() return self._pidf @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPIncomingSubscriptionDidEnd(self, notification): subscription = notification.sender notification.center.remove_observer(self, sender=subscription) self._sip_subscriptions.remove(subscription) if XMPPGatewayConfig.log_presence: log.info('SIP subscription from %s to %s removed from presence flow 0x%x (%d subs)' % (format_uri(self.sip_identity.uri, 'sip'), format_uri(self.xmpp_identity.uri, 'xmpp'), id(self), len(self._sip_subscriptions))) if not self._sip_subscriptions: self.end() def _NH_SIPIncomingSubscriptionNotifyDidFail(self, notification): if XMPPGatewayConfig.log_presence: log.info('Sending SIP NOTIFY failed from %s to %s for presence flow 0x%x: %s (%s)' % (format_uri(self.xmpp_identity.uri, 'xmpp'), format_uri(self.sip_identity.uri, 'sip'), id(self), notification.data.code, notification.data.reason)) def _NH_SIPIncomingSubscriptionGotUnsubscribe(self, notification): if XMPPGatewayConfig.log_presence: log.info('SIP subscription from %s to %s was terminated by user for presence flow 1x%x (%d subs)' % (format_uri(self.sip_identity.uri, 'sip'), format_uri(self.xmpp_identity.uri, 'xmpp'), id(self), len(self._sip_subscriptions))) def _NH_SIPIncomingSubscriptionGotRefreshingSubscribe(self, notification): if XMPPGatewayConfig.log_presence: log.info('SIP subscription from %s to %s was refreshed for presence flow 0x%x (%d subs)' % (format_uri(self.sip_identity.uri, 'sip'), format_uri(self.xmpp_identity.uri, 'xmpp'), id(self), len(self._sip_subscriptions))) def _NH_SIPIncomingSubscriptionDidTimeout(self, notification): if XMPPGatewayConfig.log_presence: log.info('SIP subscription from %s to %s timed out for presence flow 0x%x (%d subs)' % (format_uri(self.sip_identity.uri, 'sip'), format_uri(self.xmpp_identity.uri, 'xmpp'), id(self), len(self._sip_subscriptions))) def _NH_XMPPSubscriptionChangedState(self, notification): if notification.data.prev_state == 'subscribe_sent' and notification.data.state == 'active': pidf_doc = self._pidf content_type = pidf.PIDFDocument.content_type if pidf_doc is not None else None for subscription in (subscription for subscription in self._sip_subscriptions if subscription.state == 'pending'): subscription.accept(content_type, pidf_doc) def _NH_XMPPSubscriptionGotNotify(self, notification): stanza = notification.data.presence self._stanza_cache[stanza.sender.uri] = stanza stanza.timestamp = ISOTimestamp.now() # TODO: mirror the one in the stanza, if present pidf_doc = self._build_pidf() if XMPPGatewayConfig.log_presence: log.info('XMPP notification from %s to %s for presence flow 0x%x' % (format_uri(self.xmpp_identity.uri, 'xmpp'), format_uri(self.sip_identity.uri, 'sip'), id(self))) for subscription in self._sip_subscriptions: try: subscription.push_content(pidf.PIDFDocument.content_type, pidf_doc) except SIPCoreError as e: if XMPPGatewayConfig.log_presence: log.info('Failed to send SIP NOTIFY from %s to %s for presence flow 0x%x: %s' % (format_uri(self.xmpp_identity.uri, 'xmpp'), format_uri(self.sip_identity.uri, 'sip'), id(self), e)) if not stanza.available: # Only inform once about this device being unavailable del self._stanza_cache[stanza.sender.uri] def _NH_XMPPSubscriptionDidFail(self, notification): notification.center.remove_observer(self, sender=self._xmpp_subscription) self._xmpp_subscription = None self.end() _NH_XMPPSubscriptionDidEnd = _NH_XMPPSubscriptionDidFail class InterruptSubscription(Exception): pass class TerminateSubscription(Exception): pass class SubscriptionError(Exception): def __init__(self, error, timeout, refresh_interval=None, fatal=False): self.error = error self.refresh_interval = refresh_interval self.timeout = timeout self.fatal = fatal class SIPSubscriptionDidFail(Exception): def __init__(self, data): self.data = data +@implementer(IObserver) class X2SPresenceHandler(object): - implements(IObserver) sip_identity = WriteOnceAttribute() xmpp_identity = WriteOnceAttribute() def __init__(self, sip_identity, xmpp_identity): self.ended = False self.sip_identity = sip_identity self.xmpp_identity = xmpp_identity self.subscribed = False self._command_proc = None self._command_channel = coros.queue() self._data_channel = coros.queue() self._sip_subscription = None self._sip_subscription_proc = None self._sip_subscription_timer = None self._xmpp_subscription = None def start(self): notification_center = NotificationCenter() self._xmpp_subscription = XMPPIncomingSubscription(local_identity=self.sip_identity, remote_identity=self.xmpp_identity) notification_center.add_observer(self, sender=self._xmpp_subscription) self._xmpp_subscription.start() self._command_proc = proc.spawn(self._run) self._subscribe_sip() notification_center.post_notification('X2SPresenceHandlerDidStart', sender=self) def end(self): if self.ended: return notification_center = NotificationCenter() if self._xmpp_subscription is not None: notification_center.remove_observer(self, sender=self._xmpp_subscription) self._xmpp_subscription.end() self._xmpp_subscription = None if self._sip_subscription: self._unsubscribe_sip() self.ended = True notification_center.post_notification('X2SPresenceHandlerDidEnd', sender=self) @run_in_green_thread def _subscribe_sip(self): command = Command('subscribe') self._command_channel.send(command) @run_in_green_thread def _unsubscribe_sip(self): command = Command('unsubscribe') self._command_channel.send(command) command.wait() self._command_proc.kill() self._command_proc = None def _run(self): while True: command = self._command_channel.wait() handler = getattr(self, '_CH_%s' % command.name) handler(command) def _CH_subscribe(self, command): if self._sip_subscription_timer is not None and self._sip_subscription_timer.active(): self._sip_subscription_timer.cancel() self._sip_subscription_timer = None if self._sip_subscription_proc is not None: subscription_proc = self._sip_subscription_proc subscription_proc.kill(InterruptSubscription) subscription_proc.wait() self._sip_subscription_proc = proc.spawn(self._sip_subscription_handler, command) def _CH_unsubscribe(self, command): # Cancel any timer which would restart the subscription process if self._sip_subscription_timer is not None and self._sip_subscription_timer.active(): self._sip_subscription_timer.cancel() self._sip_subscription_timer = None if self._sip_subscription_proc is not None: subscription_proc = self._sip_subscription_proc subscription_proc.kill(TerminateSubscription) subscription_proc.wait() self._sip_subscription_proc = None command.signal() def _process_pidf(self, body): try: pidf_doc = pidf.PIDF.parse(body) except ParserError as e: log.warn('Error parsing PIDF document: %s' % e) return # Build XML stanzas out of PIDF documents try: person = next(p for p in pidf_doc.persons) except StopIteration: person = None for service in pidf_doc.services: sip_contact = self.sip_identity.uri.as_sip_uri() if service.device_info is not None: sip_contact.parameters['gr'] = 'urn:uuid:%s' % service.device_info.id else: sip_contact.parameters['gr'] = service.id sender = Identity(FrozenURI.parse(sip_contact)) if service.status.extended is not None: available = service.status.extended != 'offline' else: available = service.status.basic == 'open' stanza = AvailabilityPresence(sender, self.xmpp_identity, available) for note in service.notes: stanza.statuses[note.lang] = note if service.status.extended is not None: if service.status.extended == 'away': stanza.show = 'away' elif service.status.extended == 'busy': stanza.show = 'dnd' elif person is not None and person.activities is not None: activities = set(list(person.activities)) if 'away' in activities: stanza.show = 'away' elif {'holiday', 'vacation'}.intersection(activities): stanza.show = 'xa' elif 'busy' in activities: stanza.show = 'dnd' self._xmpp_subscription.send_presence(stanza) def _sip_subscription_handler(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() account = DefaultAccount() refresh_interval = getattr(command, 'refresh_interval', None) or account.sip.subscribe_interval try: # Lookup routes if account.sip.outbound_proxy is not None: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) else: uri = SIPURI(host=self.sip_identity.uri.as_sip_uri().host) lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError as e: timeout = random.uniform(15, 30) raise SubscriptionError(error='DNS lookup failed: %s' % e, timeout=timeout) timeout = time() + 30 for route in routes: remaining_time = timeout - time() if remaining_time > 0: transport = route.transport parameters = {} if transport=='udp' else {'transport': transport} contact_uri = SIPURI(user=account.contact.username, host=SIPConfig.local_ip.normalized, port=getattr(Engine(), '%s_port' % transport), parameters=parameters) subscription_uri = self.sip_identity.uri.as_sip_uri() subscription = Subscription(subscription_uri, FromHeader(self.xmpp_identity.uri.as_sip_uri()), ToHeader(subscription_uri), ContactHeader(contact_uri), 'presence', RouteHeader(route.uri), refresh=refresh_interval) notification_center.add_observer(self, sender=subscription) try: subscription.subscribe(timeout=limit(remaining_time, min=1, max=5)) except SIPCoreError: notification_center.remove_observer(self, sender=subscription) raise SubscriptionError(error='Internal error', timeout=5) self._sip_subscription = subscription try: while True: notification = self._data_channel.wait() if notification.sender is subscription and notification.name == 'SIPSubscriptionDidStart': break except SIPSubscriptionDidFail as e: notification_center.remove_observer(self, sender=subscription) self._sip_subscription = None if e.data.code == 407: # Authentication failed, so retry the subscription in some time raise SubscriptionError(error='Authentication failed', timeout=random.uniform(60, 120)) elif e.data.code == 403: # Forbidden raise SubscriptionError(error='Forbidden', timeout=None, fatal=True) elif e.data.code == 423: # Get the value of the Min-Expires header if e.data.min_expires is not None and e.data.min_expires > refresh_interval: interval = e.data.min_expires else: interval = None raise SubscriptionError(error='Interval too short', timeout=random.uniform(60, 120), refresh_interval=interval) elif e.data.code in (405, 406, 489): raise SubscriptionError(error='Method or event not supported', timeout=None, fatal=True) elif e.data.code == 1400: raise SubscriptionError(error=e.data.reason, timeout=None, fatal=True) else: # Otherwise just try the next route continue else: self.subscribed = True command.signal() break else: # There are no more routes to try, give up raise SubscriptionError(error='No more routes to try', timeout=None, fatal=True) # At this point it is subscribed. Handle notifications and ending/failures. try: while True: notification = self._data_channel.wait() if notification.sender is not self._sip_subscription: continue if self._xmpp_subscription is None: continue if notification.name == 'SIPSubscriptionGotNotify': if notification.data.event == 'presence': subscription_state = notification.data.headers.get('Subscription-State').state if subscription_state == 'active' and self._xmpp_subscription.state != 'active': self._xmpp_subscription.accept() elif subscription_state == 'pending' and self._xmpp_subscription.state == 'active': # The state went from active to pending, hide the presence state? pass if notification.data.body: if XMPPGatewayConfig.log_presence: log.info('SIP NOTIFY from %s to %s' % (format_uri(self.sip_identity.uri, 'sip'), format_uri(self.xmpp_identity.uri, 'xmpp'))) self._process_pidf(notification.data.body) elif notification.name == 'SIPSubscriptionDidEnd': break except SIPSubscriptionDidFail as e: if e.data.code == 0 and e.data.reason == 'rejected': self._xmpp_subscription.reject() else: self._command_channel.send(Command('subscribe')) notification_center.remove_observer(self, sender=self._sip_subscription) except InterruptSubscription as e: if not self.subscribed: command.signal(e) if self._sip_subscription is not None: notification_center.remove_observer(self, sender=self._sip_subscription) try: self._sip_subscription.end(timeout=2) except SIPCoreError: pass except TerminateSubscription as e: if not self.subscribed: command.signal(e) if self._sip_subscription is not None: try: self._sip_subscription.end(timeout=2) except SIPCoreError: pass else: try: while True: notification = self._data_channel.wait() if notification.sender is self._sip_subscription and notification.name == 'SIPSubscriptionDidEnd': break except SIPSubscriptionDidFail: pass finally: notification_center.remove_observer(self, sender=self._sip_subscription) except SubscriptionError as e: if not e.fatal: self._sip_subscription_timer = reactor.callLater(e.timeout, self._command_channel.send, Command('subscribe', command.event, refresh_interval=e.refresh_interval)) finally: self.subscribed = False self._sip_subscription = None self._sip_subscription_proc = None reactor.callLater(0, self.end) @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSubscriptionDidStart(self, notification): self._data_channel.send(notification) def _NH_SIPSubscriptionDidEnd(self, notification): self._data_channel.send(notification) def _NH_SIPSubscriptionDidFail(self, notification): self._data_channel.send_exception(SIPSubscriptionDidFail(notification.data)) def _NH_SIPSubscriptionGotNotify(self, notification): self._data_channel.send(notification) def _NH_XMPPIncomingSubscriptionGotUnsubscribe(self, notification): self.end() def _NH_XMPPIncomingSubscriptionGotSubscribe(self, notification): if self._sip_subscription is not None and self._sip_subscription.state.lower() == 'active': self._xmpp_subscription.accept() _NH_XMPPIncomingSubscriptionGotProbe = _NH_XMPPIncomingSubscriptionGotSubscribe diff --git a/sylk/applications/xmppgateway/xmpp/__init__.py b/sylk/applications/xmppgateway/xmpp/__init__.py index f23079e..5bba097 100644 --- a/sylk/applications/xmppgateway/xmpp/__init__.py +++ b/sylk/applications/xmppgateway/xmpp/__init__.py @@ -1,360 +1,361 @@ -import os - from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.types import Singleton -from twisted.internet import reactor, ssl +from twisted.internet import reactor from wokkel.disco import DiscoClientProtocol from wokkel.generic import FallbackHandler, VersionHandler from wokkel.ping import PingHandler from wokkel.server import ServerService, XMPPS2SServerFactory -from zope.interface import implements +from zope.interface import implementer from sylk import __version__ as SYLK_VERSION from sylk.applications.xmppgateway.configuration import XMPPGatewayConfig from sylk.applications.xmppgateway.datatypes import FrozenURI from sylk.applications.xmppgateway.logger import log from sylk.applications.xmppgateway.xmpp.jingle.session import JingleSession, JingleSessionManager from sylk.applications.xmppgateway.xmpp.protocols import DiscoProtocol, JingleProtocol, MessageProtocol, MUCServerProtocol, MUCPresenceProtocol, PresenceProtocol from sylk.applications.xmppgateway.xmpp.server import SylkInternalComponent, SylkRouter from sylk.applications.xmppgateway.xmpp.session import XMPPChatSessionManager, XMPPMucSessionManager from sylk.applications.xmppgateway.xmpp.subscription import XMPPSubscriptionManager -class XMPPManager(object): - __metaclass__ = Singleton - implements(IObserver) +import os +from twisted.internet.ssl import DefaultOpenSSLContextFactory + + +@implementer(IObserver) +class XMPPManager(object, metaclass=Singleton): def __init__(self): config = XMPPGatewayConfig self.stopped = False self.domains = set(config.domains) self.muc_domains = set('%s.%s' % (config.muc_prefix, domain) for domain in self.domains) router = SylkRouter() self._server_service = ServerService(router) self._server_service.domains = self.domains | self.muc_domains self._server_service.logTraffic = False # done manually self._s2s_factory = XMPPS2SServerFactory(self._server_service) self._s2s_factory.logTraffic = False # done manually # Setup internal components self._internal_component = SylkInternalComponent(router) self._internal_component.domains = self.domains self._internal_component.manager = self self._muc_component = SylkInternalComponent(router) self._muc_component.domains = self.muc_domains self._muc_component.manager = self # Setup protocols self.message_protocol = MessageProtocol() self.message_protocol.setHandlerParent(self._internal_component) self.presence_protocol = PresenceProtocol() self.presence_protocol.setHandlerParent(self._internal_component) self.disco_protocol = DiscoProtocol() self.disco_protocol.setHandlerParent(self._internal_component) self.disco_client_protocol = DiscoClientProtocol() self.disco_client_protocol.setHandlerParent(self._internal_component) self.muc_protocol = MUCServerProtocol() self.muc_protocol.setHandlerParent(self._muc_component) self.muc_presence_protocol = MUCPresenceProtocol() self.muc_presence_protocol.setHandlerParent(self._muc_component) self.disco_muc_protocol = DiscoProtocol() self.disco_muc_protocol.setHandlerParent(self._muc_component) self.version_protocol = VersionHandler('SylkServer', SYLK_VERSION) self.version_protocol.setHandlerParent(self._internal_component) self.fallback_protocol = FallbackHandler() self.fallback_protocol.setHandlerParent(self._internal_component) self.fallback_muc_protocol = FallbackHandler() self.fallback_muc_protocol.setHandlerParent(self._muc_component) self.ping_protocol = PingHandler() self.ping_protocol.setHandlerParent(self._internal_component) self.jingle_protocol = JingleProtocol() self.jingle_protocol.setHandlerParent(self._internal_component) self.jingle_coin_protocol = JingleProtocol() self.jingle_coin_protocol.setHandlerParent(self._muc_component) self._s2s_listener = None self.chat_session_manager = XMPPChatSessionManager() self.muc_session_manager = XMPPMucSessionManager() self.subscription_manager = XMPPSubscriptionManager() self.jingle_session_manager = JingleSessionManager() def start(self): self.stopped = False # noinspection PyUnresolvedReferences interface = XMPPGatewayConfig.local_ip port = XMPPGatewayConfig.local_port cert_path = XMPPGatewayConfig.certificate.normalized if XMPPGatewayConfig.certificate else None cert_chain_path = XMPPGatewayConfig.ca_file.normalized if XMPPGatewayConfig.ca_file else None - if XMPPGatewayConfig.transport == 'tls': if cert_path is not None: if not os.path.isfile(cert_path): log.error('Certificate file %s could not be found' % cert_path) return try: - ssl_ctx_factory = ssl.DefaultOpenSSLContextFactory(cert_path, cert_path) + ssl_ctx_factory = DefaultOpenSSLContextFactory(cert_path, cert_path) except Exception: log.exception('Creating TLS context') return if cert_chain_path is not None: if not os.path.isfile(cert_chain_path): log.error('Certificate chain file %s could not be found' % cert_chain_path) return ssl_ctx = ssl_ctx_factory.getContext() try: ssl_ctx.use_certificate_chain_file(cert_chain_path) except Exception: log.exception('Setting TLS certificate chain file') return - self._s2s_listener = reactor.listenSSL(port, self._s2s_factory, ssl_ctx_factory, interface=interface) + self._s2s_listener = reactor.listenTLS(port, self._s2s_factory, ssl_ctx_factory, interface=interface) else: self._s2s_listener = reactor.listenTCP(port, self._s2s_factory, interface=interface) + port = self._s2s_listener.getHost().port listen_address = self._s2s_listener.getHost() log.info("XMPP S2S component listening on %s:%d (%s)" % (listen_address.host, listen_address.port, XMPPGatewayConfig.transport.upper())) + self.chat_session_manager.start() self.muc_session_manager.start() self.subscription_manager.start() self.jingle_session_manager.start() notification_center = NotificationCenter() notification_center.add_observer(self, sender=self._internal_component) notification_center.add_observer(self, sender=self._muc_component) self._internal_component.startService() self._muc_component.startService() def stop(self): self.stopped = True self._s2s_listener.stopListening() self.jingle_session_manager.stop() self.subscription_manager.stop() self.muc_session_manager.stop() self.chat_session_manager.stop() self._internal_component.stopService() self._muc_component.stopService() notification_center = NotificationCenter() notification_center.remove_observer(self, sender=self._internal_component) notification_center.remove_observer(self, sender=self._muc_component) def send_stanza(self, stanza): if self.stopped: return self._internal_component.send(stanza.to_xml_element()) def send_muc_stanza(self, stanza): if self.stopped: return self._muc_component.send(stanza.to_xml_element()) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) # Process message stanzas def _NH_XMPPGotChatMessage(self, notification): message = notification.data.message try: session = self.chat_session_manager.sessions[(message.recipient.uri, message.sender.uri)] except KeyError: notification.center.post_notification('XMPPGotChatMessage', sender=self, data=notification.data) else: session.channel.send(message) def _NH_XMPPGotNormalMessage(self, notification): notification.center.post_notification('XMPPGotNormalMessage', sender=self, data=notification.data) def _NH_XMPPGotComposingIndication(self, notification): composing_indication = notification.data.composing_indication try: session = self.chat_session_manager.sessions[(composing_indication.recipient.uri, composing_indication.sender.uri)] except KeyError: notification.center.post_notification('XMPPGotComposingIndication', sender=self, data=notification.data) else: session.channel.send(composing_indication) def _NH_XMPPGotErrorMessage(self, notification): error_message = notification.data.error_message try: session = self.chat_session_manager.sessions[(error_message.recipient.uri, error_message.sender.uri)] except KeyError: notification.center.post_notification('XMPPGotErrorMessage', sender=self, data=notification.data) else: session.channel.send(error_message) def _NH_XMPPGotReceipt(self, notification): receipt = notification.data.receipt try: session = self.chat_session_manager.sessions[(receipt.recipient.uri, receipt.sender.uri)] except KeyError: pass else: session.channel.send(receipt) # Process presence stanzas def _NH_XMPPGotPresenceAvailability(self, notification): stanza = notification.data.presence_stanza if stanza.recipient.uri.resource is not None: # Skip directed presence return sender_uri = stanza.sender.uri sender_uri_bare = FrozenURI(sender_uri.user, sender_uri.host) try: subscription = self.subscription_manager.outgoing_subscriptions[(stanza.recipient.uri, sender_uri_bare)] except KeyError: # Ignore incoming presence stanzas if there is no subscription pass else: subscription.channel.send(stanza) def _NH_XMPPGotPresenceSubscriptionStatus(self, notification): stanza = notification.data.presence_stanza if stanza.sender.uri.resource is not None or stanza.recipient.uri.resource is not None: # Skip directed presence return if stanza.type in ('subscribed', 'unsubscribed'): try: subscription = self.subscription_manager.outgoing_subscriptions[(stanza.recipient.uri, stanza.sender.uri)] except KeyError: pass else: subscription.channel.send(stanza) elif stanza.type in ('subscribe', 'unsubscribe'): try: subscription = self.subscription_manager.incoming_subscriptions[(stanza.recipient.uri, stanza.sender.uri)] except KeyError: if stanza.type == 'subscribe': notification.center.post_notification('XMPPGotPresenceSubscriptionRequest', sender=self, data=NotificationData(stanza=stanza)) else: subscription.channel.send(stanza) def _NH_XMPPGotPresenceProbe(self, notification): stanza = notification.data.presence_stanza if stanza.recipient.uri.resource is not None: # Skip directed presence return sender_uri = stanza.sender.uri sender_uri_bare = FrozenURI(sender_uri.user, sender_uri.host) try: subscription = self.subscription_manager.incoming_subscriptions[(stanza.recipient.uri, sender_uri_bare)] except KeyError: notification.center.post_notification('XMPPGotPresenceSubscriptionRequest', sender=self, data=NotificationData(stanza=stanza)) else: subscription.channel.send(stanza) # Process muc stanzas def _NH_XMPPMucGotGroupChat(self, notification): message = notification.data.message muc_uri = FrozenURI(message.recipient.uri.user, message.recipient.uri.host) try: session = self.muc_session_manager.incoming[(muc_uri, message.sender.uri)] except KeyError: # Ignore groupchat messages if there was no session created pass else: session.channel.send(message) def _NH_XMPPMucGotSubject(self, notification): message = notification.data.message muc_uri = FrozenURI(message.recipient.uri.user, message.recipient.uri.host) try: session = self.muc_session_manager.incoming[(muc_uri, message.sender.uri)] except KeyError: - # Ignore groupchat messages if there was no session created pass else: session.channel.send(message) def _NH_XMPPMucGotPresenceAvailability(self, notification): stanza = notification.data.presence_stanza if not stanza.sender.uri.resource: return muc_uri = FrozenURI(stanza.recipient.uri.user, stanza.recipient.uri.host) try: session = self.muc_session_manager.incoming[(muc_uri, stanza.sender.uri)] except KeyError: if stanza.available: notification.center.post_notification('XMPPGotMucJoinRequest', sender=self, data=NotificationData(stanza=stanza)) else: notification.center.post_notification('XMPPGotMucLeaveRequest', sender=self, data=NotificationData(stanza=stanza)) else: session.channel.send(stanza) def _NH_XMPPMucGotInvitation(self, notification): invitation = notification.data.invitation data = NotificationData(sender=invitation.sender, recipient=invitation.recipient, participant=invitation.invited_user) notification.center.post_notification('XMPPGotMucAddParticipantRequest', sender=self, data=data) # Jingle def _NH_XMPPGotJingleSessionInitiate(self, notification): stanza = notification.data.stanza try: self.jingle_session_manager.sessions[stanza.jingle.sid] except KeyError: session = JingleSession(notification.data.protocol) session.init_incoming(stanza) session.send_ring_indication() def _NH_XMPPGotJingleSessionTerminate(self, notification): stanza = notification.data.stanza try: session = self.jingle_session_manager.sessions[stanza.jingle.sid] except KeyError: return session.handle_notification(notification) def _NH_XMPPGotJingleSessionInfo(self, notification): stanza = notification.data.stanza try: session = self.jingle_session_manager.sessions[stanza.jingle.sid] except KeyError: return session.handle_notification(notification) def _NH_XMPPGotJingleSessionAccept(self, notification): stanza = notification.data.stanza try: session = self.jingle_session_manager.sessions[stanza.jingle.sid] except KeyError: return session.handle_notification(notification) def _NH_XMPPGotJingleDescriptionInfo(self, notification): stanza = notification.data.stanza try: session = self.jingle_session_manager.sessions[stanza.jingle.sid] except KeyError: return session.handle_notification(notification) def _NH_XMPPGotJingleTransportInfo(self, notification): stanza = notification.data.stanza try: session = self.jingle_session_manager.sessions[stanza.jingle.sid] except KeyError: return session.handle_notification(notification) diff --git a/sylk/applications/xmppgateway/xmpp/jingle/session.py b/sylk/applications/xmppgateway/xmpp/jingle/session.py index 435a61a..88bfac0 100644 --- a/sylk/applications/xmppgateway/xmpp/jingle/session.py +++ b/sylk/applications/xmppgateway/xmpp/jingle/session.py @@ -1,821 +1,820 @@ import random import string from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.types import Singleton -from cStringIO import StringIO +from io import StringIO from datetime import datetime from eventlib import api, coros, proc from eventlib.twistedutil import block_on from lxml import etree from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import SDPSession, SDPMediaStream, SDPConnection, SDPNegotiator from sipsimple.core import SIPCoreError from sipsimple.threading import run_in_twisted_thread from twisted.internet import reactor from twisted.words.protocols.jabber.error import StanzaError from twisted.words.protocols.jabber.xmlstream import TimeoutError as IqTimeoutError -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.applications.xmppgateway.datatypes import Identity, FrozenURI from sylk.applications.xmppgateway.xmpp.jingle.streams import MediaStreamRegistry, InvalidStreamError, UnknownStreamError from sylk.applications.xmppgateway.xmpp.jingle.util import jingle_to_sdp, sdp_to_jingle from sylk.applications.xmppgateway.xmpp.stanzas import jingle from sylk.configuration import SIPConfig def random_id(): - return ''.join(random.choice(string.ascii_letters+string.digits) for x in xrange(32)) + return ''.join(random.choice(string.ascii_letters+string.digits) for x in range(32)) class MediaStreamDidFailError(Exception): def __init__(self, stream, data): self.stream = stream self.data = data class MediaStreamDidNotInitializeError(Exception): def __init__(self, stream, data): self.stream = stream self.data = data class Operation(object): __params__ = () def __init__(self, **params): - for name, value in params.iteritems(): + for name, value in params.items(): setattr(self, name, value) for param in set(self.__params__).difference(params): raise ValueError("missing operation parameter: '%s'" % param) self.channel = coros.queue() class AcceptOperation(Operation): __params__ = ('streams', 'is_focus') class SendRingIndicationOperation(Operation): __params__ = () class RejectOperation(Operation): __params__ = ('reason',) class EndOperation(Operation): __params__ = () class HoldOperation(Operation): __params__ = () class UnholdOperation(Operation): __params__ = () class ProcessRemoteOperation(Operation): __params__ = ('notification',) class ConnectOperation(Operation): __params__ = ('sender', 'recipient', 'streams', 'is_focus') class SendConferenceInfoOperation(Operation): __params__ = ('xml',) +@implementer(IObserver) class JingleSession(object): - implements(IObserver) jingle_stanza_timeout = 3 media_stream_timeout = 15 def __init__(self, protocol): self.account = DefaultAccount() self._protocol = protocol self._id = None self._local_identity = None self._remote_identity = None self._local_jid = None self._remote_jid = None self._channel = coros.queue() self._current_operation = None self._proc = proc.spawn(self._run) self._timer = None self._sdp_negotiator = None self._pending_transport_info_stanzas = [] self.direction = None self.state = None self.streams = None self.proposed_streams = None self.start_time = None self.end_time = None self.on_hold = False self.local_focus = False self.candidates = set() def init_incoming(self, stanza): self._id = stanza.jingle.sid self._local_identity = Identity(FrozenURI.parse(stanza.recipient)) self._remote_identity = Identity(FrozenURI.parse(stanza.sender)) self._local_jid = self._local_identity.uri.as_xmpp_jid() self._remote_jid = self._remote_identity.uri.as_xmpp_jid() remote_sdp = jingle_to_sdp(stanza.jingle) try: self._sdp_negotiator = SDPNegotiator.create_with_remote_offer(remote_sdp) except SIPCoreError as e: self._fail(originator='local', reason='general-error', description=str(e)) return self.proposed_streams = [] for index, media_stream in enumerate(remote_sdp.media): if media_stream.port != 0: for stream_type in MediaStreamRegistry: try: stream = stream_type.new_from_sdp(self, remote_sdp, index) except InvalidStreamError: break except UnknownStreamError: continue else: stream.index = index self.proposed_streams.append(stream) break if self.proposed_streams: self.direction = 'incoming' self.state = 'incoming' NotificationCenter().post_notification('JingleSessionNewIncoming', sender=self, data=NotificationData(streams=self.proposed_streams)) else: self._fail(originator='local', reason='unsupported-applications') def connect(self, sender_identity, recipient_identity, streams, is_focus=False): self._schedule_operation(ConnectOperation(sender=sender_identity, recipient=recipient_identity, streams=streams, is_focus=is_focus)) def send_ring_indication(self): self._schedule_operation(SendRingIndicationOperation()) def accept(self, streams, is_focus=False): self._schedule_operation(AcceptOperation(streams=streams, is_focus=is_focus)) def reject(self, reason='busy'): self._schedule_operation(RejectOperation(reason=reason)) def hold(self): self._schedule_operation(HoldOperation()) def unhold(self): self._schedule_operation(UnholdOperation()) def end(self): self._schedule_operation(EndOperation()) def add_stream(self): raise NotImplementedError def remove_stream(self): raise NotImplementedError @property def id(self): return self._id @property def local_identity(self): return self._local_identity @property def remote_identity(self): return self._remote_identity @run_in_twisted_thread def _send_conference_info(self, xml): # This function is not meant for users to call, entities with knowledge about JingleSession # internals will call it, such as the MediaSessionHandler self._schedule_operation(SendConferenceInfoOperation(xml=xml)) def _send_stanza(self, stanza): if self.direction == 'incoming': - stanza.jingle.initiator = unicode(self._remote_jid) - stanza.jingle.responder = unicode(self._local_jid) + stanza.jingle.initiator = str(self._remote_jid) + stanza.jingle.responder = str(self._local_jid) else: - stanza.jingle.initiator = unicode(self._local_jid) - stanza.jingle.responder = unicode(self._remote_jid) + stanza.jingle.initiator = str(self._local_jid) + stanza.jingle.responder = str(self._remote_jid) stanza.timeout = self.jingle_stanza_timeout return self._protocol.request(stanza) def _fail(self, originator='local', reason='general-error', description=None): reason = jingle.Reason(jingle.ReasonType(reason), text=description) stanza = self._protocol.sessionTerminate(self._local_jid, self._remote_jid, self._id, reason) self._send_stanza(stanza) self.state = 'terminated' failure_str = '%s%s' % (reason, ' %s' % description if description else '') NotificationCenter().post_notification('JingleSessionDidFail', sender=self, data=NotificationData(originator='local', reason=failure_str)) self._channel.send_exception(proc.ProcExit) @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_RTPStreamDidEnableEncryption(self, notification): if notification.sender.type != 'audio': return audio_stream = notification.sender if audio_stream.encryption.type == 'ZRTP': # start ZRTP on the video stream, if applicable try: video_stream = next(stream for stream in self.streams or [] if stream.type=='video') except StopIteration: return if video_stream.encryption.type == 'ZRTP' and not video_stream.encryption.active: video_stream.encryption.zrtp._enable(audio_stream) def _NH_MediaStreamDidStart(self, notification): stream = notification.sender if stream.type == 'audio' and stream.encryption.type == 'ZRTP': stream.encryption.zrtp._enable() elif stream.type == 'video' and stream.encryption.type == 'ZRTP': # start ZRTP on the video stream, if applicable try: audio_stream = next(stream for stream in self.streams or [] if stream.type=='audio') except StopIteration: pass else: if audio_stream.encryption.type == 'ZRTP' and audio_stream.encryption.active: stream.encryption.zrtp._enable(audio_stream) if self._current_operation is not None: self._current_operation.channel.send(notification) def _NH_MediaStreamDidInitialize(self, notification): if self._current_operation is not None: self._current_operation.channel.send(notification) def _NH_MediaStreamDidNotInitialize(self, notification): if self._current_operation is not None: self._current_operation.channel.send_exception(MediaStreamDidNotInitializeError(notification.sender, notification.data)) def _NH_MediaStreamDidFail(self, notification): if self._current_operation is not None: self._current_operation.channel.send_exception(MediaStreamDidFailError(notification.sender, notification.data)) else: self.end() def _NH_XMPPGotJingleSessionAccept(self, notification): self._schedule_operation(ProcessRemoteOperation(notification=notification)) def _NH_XMPPGotJingleSessionTerminate(self, notification): self._schedule_operation(ProcessRemoteOperation(notification=notification)) def _NH_XMPPGotJingleSessionInfo(self, notification): self._schedule_operation(ProcessRemoteOperation(notification=notification)) def _NH_XMPPGotJingleDescriptionInfo(self, notification): self._schedule_operation(ProcessRemoteOperation(notification=notification)) def _NH_XMPPGotJingleTransportInfo(self, notification): self._schedule_operation(ProcessRemoteOperation(notification=notification)) # Operation handling @run_in_twisted_thread def _schedule_operation(self, operation): self._channel.send(operation) def _run(self): while True: self._current_operation = op = self._channel.wait() try: handler = getattr(self, '_OH_%s' % op.__class__.__name__) handler(op) except BaseException: self._proc = None raise finally: self._current_operation = None def _OH_AcceptOperation(self, operation): if self.state != 'incoming': return notification_center = NotificationCenter() settings = SIPSimpleSettings() streams = operation.streams for stream in self.proposed_streams: if stream in streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='incoming') try: wait_count = len(self.proposed_streams) while wait_count > 0: notification = operation.channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 remote_sdp = self._sdp_negotiator.current_remote local_ip = SIPConfig.local_ip.normalized local_sdp = SDPSession(local_ip.encode(), connection=SDPConnection(local_ip.encode()), name=settings.user_agent.encode()) stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, media in enumerate(remote_sdp.media): stream = stream_map.get(index, None) if stream is not None: media = stream.get_local_media(remote_sdp=remote_sdp, index=index) else: media = SDPMediaStream.new(media) media.port = 0 media.attributes = [] local_sdp.media.append(media) try: self._sdp_negotiator.set_local_answer(local_sdp) self._sdp_negotiator.negotiate() except SIPCoreError as e: self._fail(originator='local', reason='incompatible-parameters', description=str(e)) return self.local_focus = operation.is_focus notification_center.post_notification('JingleSessionWillStart', sender=self) # Get active SDPs (negotiator may make changes) local_sdp = self._sdp_negotiator.active_local remote_sdp = self._sdp_negotiator.active_remote # Build the payload and send it over payload = sdp_to_jingle(local_sdp) payload.sid = self._id if self.local_focus: payload.conference_info = jingle.ConferenceInfo(True) stanza = self._protocol.sessionAccept(self._local_jid, self._remote_jid, payload) d = self._send_stanza(stanza) block_on(d) wait_count = 0 stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, local_media in enumerate(local_sdp.media): remote_media = remote_sdp.media[index] stream = stream_map.get(index, None) if stream is not None: if remote_media.port: wait_count += 1 stream.start(local_sdp, remote_sdp, index) else: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() removed_streams = [stream for stream in self.proposed_streams if stream.index >= len(local_sdp.media)] for stream in removed_streams: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() with api.timeout(self.media_stream_timeout): while wait_count > 0: notification = operation.channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 except (MediaStreamDidNotInitializeError, MediaStreamDidFailError, api.TimeoutError, IqTimeoutError, StanzaError) as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() if isinstance(e, api.TimeoutError): error = 'media stream timed out while starting' elif isinstance(e, IqTimeoutError): error = 'timeout sending IQ stanza' elif isinstance(e, StanzaError): error = str(e.condition) else: error = 'media stream failed: %s' % e.data.reason self._fail(originator='local', reason='failed-application', description=error) else: self.state = 'connected' self.streams = self.proposed_streams self.proposed_streams = None self.start_time = datetime.now() notification_center.post_notification('JingleSessionDidStart', self, NotificationData(streams=self.streams)) def _OH_ConnectOperation(self, operation): if self.state is not None: return settings = SIPSimpleSettings() notification_center = NotificationCenter() self.direction = 'outgoing' self.state = 'connecting' self.proposed_streams = operation.streams self.local_focus = operation.is_focus self._id = random_id() self._local_identity = operation.sender self._remote_identity = operation.recipient self._local_jid = self._local_identity.uri.as_xmpp_jid() self._remote_jid = self._remote_identity.uri.as_xmpp_jid() notification_center.post_notification('JingleSessionNewOutgoing', self, NotificationData(streams=operation.streams)) for stream in self.proposed_streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='outgoing') try: wait_count = len(self.proposed_streams) while wait_count > 0: notification = operation.channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 # Build local SDP and negotiator local_ip = SIPConfig.local_ip.normalized local_sdp = SDPSession(local_ip.encode(), connection=SDPConnection(local_ip.encode()), name=settings.user_agent.encode()) for index, stream in enumerate(self.proposed_streams): stream.index = index media = stream.get_local_media(remote_sdp=None, index=index) local_sdp.media.append(media) self._sdp_negotiator = SDPNegotiator.create_with_local_offer(local_sdp) # Build the payload and send it over payload = sdp_to_jingle(local_sdp) payload.sid = self._id if self.local_focus: payload.conference_info = jingle.ConferenceInfo(True) stanza = self._protocol.sessionInitiate(self._local_jid, self._remote_jid, payload) d = self._send_stanza(stanza) block_on(d) except (MediaStreamDidNotInitializeError, MediaStreamDidFailError, IqTimeoutError, StanzaError, SIPCoreError) as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() if isinstance(e, IqTimeoutError): error = 'timeout sending IQ stanza' elif isinstance(e, StanzaError): error = str(e.condition) elif isinstance(e, SIPCoreError): error = str(e) else: error = 'media stream failed: %s' % e.data.reason self.state = 'terminated' NotificationCenter().post_notification('JingleSessionDidFail', sender=self, data=NotificationData(originator='local', reason=error)) self._channel.send_exception(proc.ProcExit) else: self._timer = reactor.callLater(settings.sip.invite_timeout, self.end) def _OH_RejectOperation(self, operation): if self.state != 'incoming': return reason = jingle.Reason(jingle.ReasonType(operation.reason)) stanza = self._protocol.sessionTerminate(self._local_jid, self._remote_jid, self._id, reason) self._send_stanza(stanza) self.state = 'terminated' self._channel.send_exception(proc.ProcExit) def _OH_EndOperation(self, operation): if self.state not in ('connecting', 'connected'): return if self._timer is not None and self._timer.active(): self._timer.cancel() self._timer = None prev_state = self.state self.state = 'terminating' notification_center = NotificationCenter() notification_center.post_notification('JingleSessionWillEnd', self) streams = (self.streams or []) + (self.proposed_streams or []) for stream in streams[:]: try: notification_center.remove_observer(self, sender=stream) except KeyError: streams.remove(stream) else: stream.deactivate() if prev_state == 'connected': reason = jingle.Reason(jingle.ReasonType('success')) else: reason = jingle.Reason(jingle.ReasonType('cancel')) stanza = self._protocol.sessionTerminate(self._local_jid, self._remote_jid, self._id, reason) self._send_stanza(stanza) self.state = 'terminated' if prev_state == 'connected': self.end_time = datetime.now() notification_center.post_notification('JingleSessionDidEnd', self, NotificationData(originator='local')) else: notification_center.post_notification('JingleSessionDidFail', self, NotificationData(originator='local', reason='cancel')) for stream in streams: stream.end() self._channel.send_exception(proc.ProcExit) def _OH_SendRingIndicationOperation(self, operation): if self.state != 'incoming': return stanza = self._protocol.sessionInfo(self._local_jid, self._remote_jid, self._id, jingle.Info('ringing')) self._send_stanza(stanza) def _OH_HoldOperation(self, operation): if self.state != 'connected': return if self.on_hold: return self.on_hold = True for stream in self.streams: stream.hold() stanza = self._protocol.sessionInfo(self._local_jid, self._remote_jid, self._id, jingle.Info('hold')) self._send_stanza(stanza) NotificationCenter().post_notification('JingleSessionDidChangeHoldState', self, NotificationData(originator='local', on_hold=True, partial=False)) def _OH_UnholdOperation(self, operation): if self.state != 'connected': return if not self.on_hold: return self.on_hold = False for stream in self.streams: stream.unhold() stanza = self._protocol.sessionInfo(self._local_jid, self._remote_jid, self._id, jingle.Info('unhold')) self._send_stanza(stanza) NotificationCenter().post_notification('JingleSessionDidChangeHoldState', self, NotificationData(originator='local', on_hold=False, partial=False)) def _OH_SendConferenceInfoOperation(self, operation): if self.state != 'connected': return if not self.local_focus: return tree = etree.parse(StringIO(operation.xml)) tree.getroot().attrib['sid'] = self._id # FIXME: non-standard, but Jitsi does it data = etree.tostring(tree, xml_declaration=False) # Strip the XML heading stanza = jingle.ConferenceInfoIq(sender=self._local_jid, recipient=self._remote_jid, payload=data) stanza.timeout = self.jingle_stanza_timeout self._protocol.request(stanza) def _OH_ProcessRemoteOperation(self, operation): notification = operation.notification stanza = notification.data.stanza if notification.name == 'XMPPGotJingleSessionTerminate': if self.state not in ('incoming', 'connecting', 'connected_pending_accept', 'connected'): return if self._timer is not None and self._timer.active(): self._timer.cancel() self._timer = None # Session ended remotely prev_state = self.state self.state = 'terminated' if prev_state == 'incoming': reason = stanza.jingle.reason.value if stanza.jingle.reason else 'cancel' notification.center.post_notification('JingleSessionDidFail', self, NotificationData(originator='remote', reason=reason)) else: notification.center.post_notification('JingleSessionWillEnd', self, NotificationData(originator='remote')) streams = self.proposed_streams if prev_state == 'connecting' else self.streams for stream in streams: notification.center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.end_time = datetime.now() notification.center.post_notification('JingleSessionDidEnd', self, NotificationData(originator='remote')) self._channel.send_exception(proc.ProcExit) elif notification.name == 'XMPPGotJingleSessionInfo': info = stanza.jingle.info if not info: return if info == 'ringing': if self.state not in ('connecting', 'connected_pending_accept'): return notification.center.post_notification('JingleSessionGotRingIndication', self) elif info in ('hold', 'unhold'): if self.state != 'connected': return notification.center.post_notification('JingleSessionDidChangeHoldState', self, NotificationData(originator='remote', on_hold=info=='hold', partial=False)) elif notification.name == 'XMPPGotJingleDescriptionInfo': if self.state != 'connecting': return # Add candidates acquired on transport-info stanzas for s in self._pending_transport_info_stanzas: for c in s.jingle.content: content = next(content for content in stanza.jingle.content if content.name == c.name) content.transport.candidates.extend(c.transport.candidates) if isinstance(content.transport, jingle.IceUdpTransport): if not content.transport.ufrag and c.transport.ufrag: content.transport.ufrag = c.transport.ufrag if not content.transport.password and c.transport.password: content.transport.password = c.transport.password remote_sdp = jingle_to_sdp(stanza.jingle) try: self._sdp_negotiator.set_remote_answer(remote_sdp) self._sdp_negotiator.negotiate() except SIPCoreError: # The description-info stanza may have been just a parameter change, not a full 'SDP' return if self._timer is not None and self._timer.active(): self._timer.cancel() self._timer = None del self._pending_transport_info_stanzas[:] # Get active SDPs (negotiator may make changes) local_sdp = self._sdp_negotiator.active_local remote_sdp = self._sdp_negotiator.active_remote notification.center.post_notification('JingleSessionWillStart', sender=self) stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, local_media in enumerate(local_sdp.media): remote_media = remote_sdp.media[index] stream = stream_map[index] if remote_media.port: stream.start(local_sdp, remote_sdp, index) else: notification.center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() removed_streams = [stream for stream in self.proposed_streams if stream.index >= len(local_sdp.media)] for stream in removed_streams: notification.center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() try: with api.timeout(self.media_stream_timeout): wait_count = len(self.proposed_streams) while wait_count > 0: notification = operation.channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 except (MediaStreamDidFailError, api.TimeoutError) as e: for stream in self.proposed_streams: notification.center.remove_observer(self, sender=stream) stream.deactivate() stream.end() if isinstance(e, api.TimeoutError): error = 'media stream timed out while starting' else: error = 'media stream failed: %s' % e.data.reason self._fail(originator='local', reason='failed-application', description=error) else: self.state = 'connected_pending_accept' self.streams = self.proposed_streams self.proposed_streams = None self.start_time = datetime.now() # Hold the streams to prevent real RTP from flowing for stream in self.streams: stream.hold() elif notification.name == 'XMPPGotJingleSessionAccept': if self.state not in ('connecting', 'connected_pending_accept'): return if self._timer is not None and self._timer.active(): self._timer.cancel() self._timer = None if self.state == 'connected_pending_accept': # We already negotiated ICE and media is 'flowing' (not really because streams are on hold) # unhold the streams and pretend the session just started for stream in self.streams: stream.unhold() self.state = 'connected' notification.center.post_notification('JingleSessionDidStart', self, NotificationData(streams=self.streams)) return # Add candidates acquired on transport-info stanzas for s in self._pending_transport_info_stanzas: for c in s.jingle.content: content = next(content for content in stanza.jingle.content if content.name == c.name) content.transport.candidates.extend(c.transport.candidates) if isinstance(content.transport, jingle.IceUdpTransport): if not content.transport.ufrag and c.transport.ufrag: content.transport.ufrag = c.transport.ufrag if not content.transport.password and c.transport.password: content.transport.password = c.transport.password del self._pending_transport_info_stanzas[:] remote_sdp = jingle_to_sdp(stanza.jingle) try: self._sdp_negotiator.set_remote_answer(remote_sdp) self._sdp_negotiator.negotiate() except SIPCoreError as e: for stream in self.proposed_streams: notification.center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='remote', reason='incompatible-parameters', description=str(e)) return # Get active SDPs (negotiator may make changes) local_sdp = self._sdp_negotiator.active_local remote_sdp = self._sdp_negotiator.active_remote notification.center.post_notification('JingleSessionWillStart', sender=self) stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, local_media in enumerate(local_sdp.media): remote_media = remote_sdp.media[index] stream = stream_map[index] if remote_media.port: stream.start(local_sdp, remote_sdp, index) else: notification.center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() removed_streams = [stream for stream in self.proposed_streams if stream.index >= len(local_sdp.media)] for stream in removed_streams: notification.center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() try: with api.timeout(self.media_stream_timeout): wait_count = len(self.proposed_streams) while wait_count > 0: notification = operation.channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 except (MediaStreamDidFailError, api.TimeoutError) as e: for stream in self.proposed_streams: notification.center.remove_observer(self, sender=stream) stream.deactivate() stream.end() if isinstance(e, api.TimeoutError): error = 'media stream timed out while starting' else: error = 'media stream failed: %s' % e.data.reason self._fail(originator='local', reason='failed-application', description=error) else: self.state = 'connected' self.streams = self.proposed_streams self.proposed_streams = None self.start_time = datetime.now() notification.center.post_notification('JingleSessionDidStart', self, NotificationData(streams=self.streams)) elif notification.name == 'XMPPGotJingleTransportInfo': if self.state != 'connecting': # ICE trickling not supported yet, so only accept candidates before accept return for c in stanza.jingle.content: content = next(content for content in stanza.jingle.content if content.name == c.name) content.transport.candidates.extend(c.transport.candidates) if isinstance(content.transport, jingle.IceUdpTransport) or isinstance(content.transport, jingle.RawUdpTransport): for cand in content.transport.candidates: if cand.port == 0: continue idx = "%s:%s:%s" % (cand.protocol, cand.ip, cand.port) if idx in self.candidates: continue self.candidates.add(idx) self._pending_transport_info_stanzas.append(stanza) -class JingleSessionManager(object): - __metaclass__ = Singleton - implements(IObserver) +@implementer(IObserver) +class JingleSessionManager(object, metaclass=Singleton): def __init__(self): self.sessions = {} def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, name='JingleSessionNewIncoming') notification_center.add_observer(self, name='JingleSessionNewOutgoing') notification_center.add_observer(self, name='JingleSessionDidFail') notification_center.add_observer(self, name='JingleSessionDidEnd') def stop(self): notification_center = NotificationCenter() notification_center.remove_observer(self, name='JingleSessionNewIncoming') notification_center.remove_observer(self, name='JingleSessionNewOutgoing') notification_center.remove_observer(self, name='JingleSessionDidFail') notification_center.remove_observer(self, name='JingleSessionDidEnd') def handle_notification(self, notification): if notification.name in ('JingleSessionNewIncoming', 'JingleSessionNewOutgoing'): session = notification.sender self.sessions[session.id] = session elif notification.name in ('JingleSessionDidFail', 'JingleSessionDidEnd'): session = notification.sender del self.sessions[session.id] diff --git a/sylk/applications/xmppgateway/xmpp/jingle/streams/__init__.py b/sylk/applications/xmppgateway/xmpp/jingle/streams/__init__.py index 0828038..05696bf 100644 --- a/sylk/applications/xmppgateway/xmpp/jingle/streams/__init__.py +++ b/sylk/applications/xmppgateway/xmpp/jingle/streams/__init__.py @@ -1,123 +1,121 @@ """ This module automatically registers media streams to a stream registry allowing for a plug and play mechanism of various types of media negoticated in a SIP session that can be added to this library by using a generic API. For actual usage see rtp.py and msrp.py that implement media streams based on their respective RTP and MSRP protocols. """ from operator import attrgetter from application.python.types import Singleton from zope.interface import Interface, Attribute class StreamError(Exception): pass class InvalidStreamError(StreamError): pass class UnknownStreamError(StreamError): pass # The MediaStream interface # class IMediaStream(Interface): type = Attribute("A string identifying the stream type (ex: audio, video, ...)") priority = Attribute("An integer value indicating the stream priority relative to the other streams types (higher numbers have higher priority).") session = Attribute("Session object to which this stream is attached") hold_supported = Attribute("True if the stream supports hold") on_hold_by_local = Attribute("True if the stream is on hold by the local party") on_hold_by_remote = Attribute("True if the stream is on hold by the remote") on_hold = Attribute("True if either on_hold_by_local or on_hold_by_remote is true") # this should be a classmethod, but zopeinterface complains if we decorate it with @classmethod -Dan def new_from_sdp(cls, session, remote_sdp, stream_index): pass def get_local_media(self, for_offer): pass def initialize(self, session, direction): pass def start(self, local_sdp, remote_sdp, stream_index): pass def deactivate(self): pass def end(self): pass def validate_update(self, remote_sdp, stream_index): pass def update(self, local_sdp, remote_sdp, stream_index): pass def hold(self): pass def unhold(self): pass def reset(self, stream_index): pass # The MediaStream registry # class StreamDescriptor(object): def __init__(self, type): self.type = type def __get__(self, obj, objtype): return self if obj is None else obj.get(self.type) def __set__(self, obj, value): raise AttributeError('cannot set attribute') def __delete__(self, obj): raise AttributeError('cannot delete attribute') -class MediaStreamRegistry(object): - __metaclass__ = Singleton - +class MediaStreamRegistry(object, metaclass=Singleton): def __init__(self): self.__types__ = [] def __iter__(self): return iter(self.__types__) def add(self, cls): if cls.priority is not None and cls not in self.__types__: self.__types__.append(cls) self.__types__.sort(key=attrgetter('priority'), reverse=True) - setattr(self.__class__, cls.type.title().translate(None, ' -_') + 'Stream', StreamDescriptor(cls.type)) + setattr(self.__class__, cls.type.title().translate(str.maketrans(' ', ' ', ' -_')) + 'Stream', StreamDescriptor(cls.type)) def get(self, type): try: return next(cls for cls in self.__types__ if cls.type == type) except StopIteration: raise UnknownStreamError("unknown stream type: %s" % type) MediaStreamRegistry = MediaStreamRegistry() class MediaStreamRegistrar(type): """Metaclass for adding a MediaStream to the media stream's class registry""" def __init__(cls, name, bases, dic): super(MediaStreamRegistrar, cls).__init__(name, bases, dic) MediaStreamRegistry.add(cls) # Import the streams defined in submodules # from sylk.applications.xmppgateway.xmpp.jingle.streams import rtp from sylk.applications.xmppgateway.xmpp.jingle.streams.rtp import * __all__ = ('StreamError', 'InvalidStreamError', 'UnknownStreamError', 'IMediaStream', 'MediaStreamRegistry', 'MediaStreamRegistrar') + rtp.__all__ diff --git a/sylk/applications/xmppgateway/xmpp/jingle/streams/rtp.py b/sylk/applications/xmppgateway/xmpp/jingle/streams/rtp.py index 2acfa68..316907d 100644 --- a/sylk/applications/xmppgateway/xmpp/jingle/streams/rtp.py +++ b/sylk/applications/xmppgateway/xmpp/jingle/streams/rtp.py @@ -1,441 +1,439 @@ """ Handling of RTP media streams according to RFC3550, RFC3605, RFC3581, RFC2833 and RFC3711, RFC3489 and draft-ietf-mmusic-ice-19. """ from threading import RLock from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null -from zope.interface import implements +from zope.interface import implementer from sipsimple.audio import AudioBridge, AudioDevice, IAudioPort from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import AudioTransport, PJSIPError, RTPTransport, SIPCoreError from sipsimple.streams.rtp import RTPStreamEncryption from sylk.applications.xmppgateway.xmpp.jingle.streams import IMediaStream, InvalidStreamError, MediaStreamRegistrar, UnknownStreamError __all__ = 'AudioStream', -class AudioStream(object): - __metaclass__ = MediaStreamRegistrar - - implements(IMediaStream, IAudioPort, IObserver) +@implementer(IMediaStream, IAudioPort, IObserver) +class AudioStream(object, metaclass=MediaStreamRegistrar): type = 'audio' priority = 1 hold_supported = True def __init__(self): from sipsimple.application import SIPApplication self.mixer = SIPApplication.voice_audio_mixer self.bridge = AudioBridge(self.mixer) self.device = AudioDevice(self.mixer) self.notification_center = NotificationCenter() self.on_hold_by_local = False self.on_hold_by_remote = False self.direction = None self.state = 'NULL' self._transport = None self._hold_request = None self._ice_state = 'NULL' self._lock = RLock() self._rtp_transport = None self.session = None self.encryption = RTPStreamEncryption(self) self._srtp_encryption = None self._try_ice = False self._initialized = False self._done = False self._failure_reason = None self.bridge.add(self.device) # Audio properties # @property def codec(self): return self._transport.codec if self._transport else None @property def consumer_slot(self): return self._transport.slot if self._transport else None @property def producer_slot(self): return self._transport.slot if self._transport and not self.muted else None @property def sample_rate(self): return self._transport.sample_rate if self._transport else None @property def statistics(self): return self._transport.statistics if self._transport else None @property def muted(self): return self.__dict__.get('muted', False) @muted.setter def muted(self, value): if not isinstance(value, bool): raise ValueError('illegal value for muted property: %r' % (value,)) if value == self.muted: return old_producer_slot = self.producer_slot self.__dict__['muted'] = value notification_center = NotificationCenter() data = NotificationData(consumer_slot_changed=False, producer_slot_changed=True, old_producer_slot=old_producer_slot, new_producer_slot=self.producer_slot) notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=data) # RTP properties # @property def local_rtp_address(self): return self._rtp_transport.local_rtp_address if self._rtp_transport else None @property def local_rtp_port(self): return self._rtp_transport.local_rtp_port if self._rtp_transport else None @property def remote_rtp_address(self): if self._ice_state == 'IN_USE': return self._rtp_transport.remote_rtp_address_received if self._rtp_transport else None else: return self._rtp_transport.remote_rtp_address_sdp if self._rtp_transport else None @property def remote_rtp_port(self): if self._ice_state == 'IN_USE': return self._rtp_transport.remote_rtp_port_received if self._rtp_transport else None else: return self._rtp_transport.remote_rtp_port_sdp if self._rtp_transport else None @property def local_rtp_candidate_type(self): return self._rtp_transport.local_rtp_candidate_type if self._rtp_transport else None @property def remote_rtp_candidate_type(self): return self._rtp_transport.remote_rtp_candidate_type if self._rtp_transport else None @property def ice_active(self): return self._ice_state == 'IN_USE' # Generic properties # @property def on_hold(self): return self.on_hold_by_local or self.on_hold_by_remote # Public methods # @classmethod def new_from_sdp(cls, session, remote_sdp, stream_index): # TODO: actually validate the SDP settings = SIPSimpleSettings() remote_stream = remote_sdp.media[stream_index] if remote_stream.media != 'audio': raise UnknownStreamError if remote_stream.transport not in ('RTP/AVP', 'RTP/SAVP'): raise InvalidStreamError('expected RTP/AVP or RTP/SAVP transport in audio stream, got %s' % remote_stream.transport) local_encryption_policy = 'sdes_optional' if local_encryption_policy == 'sdes_mandatory' and not 'crypto' in remote_stream.attributes: raise InvalidStreamError("SRTP/SDES is locally mandatory but it's not remotely enabled") if remote_stream.transport == 'RTP/SAVP' and 'crypto' in remote_stream.attributes and local_encryption_policy not in ('opportunistic', 'sdes_optional', 'sdes_mandatory'): raise InvalidStreamError("SRTP/SDES is remotely mandatory but it's not locally enabled") supported_codecs = session.account.rtp.audio_codec_list or settings.rtp.audio_codec_list if not any(codec for codec in remote_stream.codec_list if codec in supported_codecs): raise InvalidStreamError('no compatible codecs found') stream = cls() stream._incoming_remote_sdp = remote_sdp stream._incoming_stream_index = stream_index return stream def initialize(self, session, direction): with self._lock: if self.state != 'NULL': raise RuntimeError('AudioStream.initialize() may only be called in the NULL state') self.state = 'INITIALIZING' self.session = session local_encryption_policy = 'sdes_optional' if hasattr(self, '_incoming_remote_sdp') and hasattr(self, '_incoming_stream_index'): # ICE attributes could come at the session level or at the media level remote_stream = self._incoming_remote_sdp.media[self._incoming_stream_index] self._try_ice = (remote_stream.has_ice_attributes or self._incoming_remote_sdp.has_ice_attributes) and remote_stream.has_ice_candidates if 'zrtp-hash' in remote_stream.attributes: incoming_stream_encryption = 'zrtp' elif 'crypto' in remote_stream.attributes: incoming_stream_encryption = 'sdes_mandatory' if remote_stream.transport == 'RTP/SAVP' else 'sdes_optional' else: incoming_stream_encryption = None if incoming_stream_encryption is not None and local_encryption_policy == 'opportunistic': self._srtp_encryption = incoming_stream_encryption else: self._srtp_encryption = 'zrtp' if local_encryption_policy == 'opportunistic' else local_encryption_policy else: self._try_ice = True self._srtp_encryption = 'zrtp' if local_encryption_policy == 'opportunistic' else local_encryption_policy self._init_rtp_transport() def get_local_media(self, remote_sdp=None, index=0): with self._lock: if self.state not in ['INITIALIZED', 'WAIT_ICE', 'ESTABLISHED']: raise RuntimeError('AudioStream.get_local_media() may only be called in the INITIALIZED, WAIT_ICE or ESTABLISHED states') if remote_sdp is None: # offer old_direction = self._transport.direction if old_direction is None: new_direction = 'sendrecv' elif 'send' in old_direction: new_direction = ('sendonly' if (self._hold_request == 'hold' or (self._hold_request is None and self.on_hold_by_local)) else 'sendrecv') else: new_direction = ('inactive' if (self._hold_request == 'hold' or (self._hold_request is None and self.on_hold_by_local)) else 'recvonly') else: new_direction = None return self._transport.get_local_media(remote_sdp, index, new_direction) def start(self, local_sdp, remote_sdp, stream_index): with self._lock: if self.state != 'INITIALIZED': raise RuntimeError('AudioStream.start() may only be called in the INITIALIZED state') settings = SIPSimpleSettings() self._transport.start(local_sdp, remote_sdp, stream_index, timeout=settings.rtp.timeout) self._check_hold(self._transport.direction, True) if self._try_ice: self.state = 'WAIT_ICE' else: self.state = 'ESTABLISHED' self.notification_center.post_notification('MediaStreamDidStart', sender=self) def validate_update(self, remote_sdp, stream_index): with self._lock: # TODO: implement return True def update(self, local_sdp, remote_sdp, stream_index): with self._lock: connection = remote_sdp.media[stream_index].connection or remote_sdp.connection if not self._rtp_transport.ice_active and (connection.address != self._rtp_transport.remote_rtp_address_sdp or self._rtp_transport.remote_rtp_port_sdp != remote_sdp.media[stream_index].port): settings = SIPSimpleSettings() old_consumer_slot = self.consumer_slot old_producer_slot = self.producer_slot self.notification_center.remove_observer(self, sender=self._transport) self._transport.stop() try: self._transport = AudioTransport(self.mixer, self._rtp_transport, remote_sdp, stream_index, codecs=list(self.session.account.rtp.audio_codec_list or settings.rtp.audio_codec_list)) except SIPCoreError as e: self.state = 'ENDED' self._failure_reason = e.args[0] self.notification_center.post_notification('MediaStreamDidFail', sender=self, data=NotificationData(reason=self._failure_reason)) return self.notification_center.add_observer(self, sender=self._transport) self._transport.start(local_sdp, remote_sdp, stream_index, timeout=settings.rtp.timeout) self.notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=True, producer_slot_changed=True, old_consumer_slot=old_consumer_slot, new_consumer_slot=self.consumer_slot, old_producer_slot=old_producer_slot, new_producer_slot=self.producer_slot)) if connection.address == '0.0.0.0' and remote_sdp.media[stream_index].direction == 'sendrecv': self._transport.update_direction('recvonly') self._check_hold(self._transport.direction, False) self.notification_center.post_notification('RTPStreamDidChangeRTPParameters', sender=self) else: new_direction = local_sdp.media[stream_index].direction self._transport.update_direction(new_direction) self._check_hold(new_direction, False) self._hold_request = None def hold(self): with self._lock: if self.on_hold_by_local or self._hold_request == 'hold': return if self.state == 'ESTABLISHED' and self.direction != 'inactive': self.bridge.remove(self) self._hold_request = 'hold' def unhold(self): with self._lock: if (not self.on_hold_by_local and self._hold_request != 'hold') or self._hold_request == 'unhold': return if self.state == 'ESTABLISHED' and self._hold_request == 'hold': self.bridge.add(self) self._hold_request = None if self._hold_request == 'hold' else 'unhold' def deactivate(self): with self._lock: self.bridge.stop() def end(self): with self._lock: if not self._initialized or self._done: return self._done = True self.notification_center.post_notification('MediaStreamWillEnd', sender=self) if self._transport is not None: self._transport.stop() self.notification_center.remove_observer(self, sender=self._transport) self._transport = None self.notification_center.remove_observer(self, sender=self._rtp_transport) self._rtp_transport = None self.state = 'ENDED' self.notification_center.post_notification('MediaStreamDidEnd', sender=self, data=NotificationData(error=self._failure_reason)) self.session = None def reset(self, stream_index): with self._lock: if self.direction == 'inactive' and not self.on_hold_by_local: new_direction = 'sendrecv' self._transport.update_direction(new_direction) self._check_hold(new_direction, False) # TODO: do a full reset, re-creating the AudioTransport, so that a new offer # would contain all codecs and ICE would be renegotiated -Saul def send_dtmf(self, digit): with self._lock: if self.state != 'ESTABLISHED': raise RuntimeError('AudioStream.send_dtmf() cannot be used in %s state' % self.state) try: self._transport.send_dtmf(digit) except PJSIPError as e: if not e.args[0].endswith('(PJ_ETOOMANY)'): raise # Notification handling # def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_RTPTransportDidFail(self, notification): with self._lock: self.notification_center.remove_observer(self, sender=notification.sender) if self.state == 'ENDED': return self._try_next_rtp_transport(notification.data.reason) def _NH_RTPTransportDidInitialize(self, notification): settings = SIPSimpleSettings() rtp_transport = notification.sender with self._lock: if self.state == 'ENDED': return del self._rtp_args del self._stun_servers try: if hasattr(self, '_incoming_remote_sdp') and hasattr(self, '_incoming_stream_index'): try: audio_transport = AudioTransport(self.mixer, rtp_transport, self._incoming_remote_sdp, self._incoming_stream_index, codecs=list(self.session.account.rtp.audio_codec_list or settings.rtp.audio_codec_list)) finally: del self._incoming_remote_sdp del self._incoming_stream_index else: audio_transport = AudioTransport(self.mixer, rtp_transport, codecs=list(self.session.account.rtp.audio_codec_list or settings.rtp.audio_codec_list)) except SIPCoreError as e: self.state = "ENDED" self.notification_center.post_notification('MediaStreamDidNotInitialize', sender=self, data=NotificationData(reason=e.args[0])) return self._rtp_transport = rtp_transport self._transport = audio_transport self.notification_center.add_observer(self, sender=audio_transport) self._initialized = True self.state = 'INITIALIZED' self.notification_center.post_notification('MediaStreamDidInitialize', sender=self) def _NH_RTPAudioStreamGotDTMF(self, notification): self.notification_center.post_notification('AudioStreamGotDTMF', sender=self, data=NotificationData(digit=notification.data.digit)) def _NH_RTPAudioTransportDidTimeout(self, notification): self.notification_center.post_notification('RTPStreamDidTimeout', sender=self) def _NH_RTPTransportICENegotiationStateDidChange(self, notification): with self._lock: if self._ice_state != 'NULL' or self.state not in ('INITIALIZING', 'INITIALIZED', 'WAIT_ICE'): return self.notification_center.post_notification('RTPStreamICENegotiationStateDidChange', sender=self, data=notification.data) def _NH_RTPTransportICENegotiationDidSucceed(self, notification): with self._lock: if self.state != 'WAIT_ICE': return self._ice_state = 'IN_USE' self.state = 'ESTABLISHED' self.notification_center.post_notification('RTPStreamICENegotiationDidSucceed', sender=self, data=notification.data) self.notification_center.post_notification('MediaStreamDidStart', sender=self) def _NH_RTPTransportICENegotiationDidFail(self, notification): with self._lock: if self.state != 'WAIT_ICE': return self._ice_state = 'FAILED' self.state = 'ESTABLISHED' self.notification_center.post_notification('RTPStreamICENegotiationDidFail', sender=self, data=notification.data) self.notification_center.post_notification('MediaStreamDidStart', sender=self) # Private methods # def _init_rtp_transport(self, stun_servers=None): self._rtp_args = dict() self._rtp_args['encryption'] = self._srtp_encryption self._rtp_args['use_ice'] = self._try_ice self._stun_servers = [(None, None)] if stun_servers: self._stun_servers.extend(reversed(stun_servers)) self._try_next_rtp_transport() def _try_next_rtp_transport(self, failure_reason=None): if self._stun_servers: stun_address, stun_port = self._stun_servers.pop() try: rtp_transport = RTPTransport(ice_stun_address=stun_address, ice_stun_port=stun_port, **self._rtp_args) except SIPCoreError as e: self._try_next_rtp_transport(e.args[0]) else: self.notification_center.add_observer(self, sender=rtp_transport) try: rtp_transport.set_INIT() except SIPCoreError as e: self.notification_center.remove_observer(self, sender=rtp_transport) self._try_next_rtp_transport(e.args[0]) else: self.state = 'ENDED' self.notification_center.post_notification('MediaStreamDidNotInitialize', sender=self, data=NotificationData(reason=failure_reason)) def _check_hold(self, direction, is_initial): was_on_hold_by_local = self.on_hold_by_local was_on_hold_by_remote = self.on_hold_by_remote was_inactive = self.direction == 'inactive' self.direction = direction inactive = self.direction == 'inactive' self.on_hold_by_local = was_on_hold_by_local if inactive else direction == 'sendonly' self.on_hold_by_remote = 'send' not in direction if (is_initial or was_on_hold_by_local or was_inactive) and not inactive and not self.on_hold_by_local and self._hold_request != 'hold': self.bridge.add(self) if not was_on_hold_by_local and self.on_hold_by_local: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator='local', on_hold=True)) if was_on_hold_by_local and not self.on_hold_by_local: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator='local', on_hold=False)) if not was_on_hold_by_remote and self.on_hold_by_remote: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator='remote', on_hold=True)) if was_on_hold_by_remote and not self.on_hold_by_remote: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator='remote', on_hold=False)) diff --git a/sylk/applications/xmppgateway/xmpp/jingle/util.py b/sylk/applications/xmppgateway/xmpp/jingle/util.py index 44c1731..db4f350 100644 --- a/sylk/applications/xmppgateway/xmpp/jingle/util.py +++ b/sylk/applications/xmppgateway/xmpp/jingle/util.py @@ -1,167 +1,167 @@ import re from collections import defaultdict from itertools import count from sipsimple.core import SDPSession, SDPMediaStream, SDPAttribute, SDPConnection from sylk.applications.xmppgateway.xmpp.stanzas import jingle __all__ = 'jingle_to_sdp', 'sdp_to_jingle' # IPv4 only for now, I'm sorry ipv4_re = re.compile("^\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}$") def content_to_sdpstream(content): if content.description is None: raise ValueError('Missing media description') if content.transport is None: raise ValueError('Missing media transport') media_stream = SDPMediaStream(content.description.media, 0, b'RTP/AVP') formats = [] attributes = [] for item in content.description.payloads: formats.append(item.id) f = '%d %s/%d' % (item.id, str(item.name), item.clockrate) attributes.append(SDPAttribute(b'rtpmap', f.encode())) if item.maxptime: attributes.append(SDPAttribute(b'maxptime', item.maxptime.encode())) if item.ptime: attributes.append(SDPAttribute(b'ptime', item.ptime.encode())) if item.parameters: parameters_str = ';'.join(('%s=%s' % (p.name, p.value) for p in item.parameters)) fmtp = '%d %s' % (item.id, str(parameters_str)) attributes.append(SDPAttribute(b'fmtp', fmtp.encode())) - media_stream.formats = map(str, formats) + media_stream.formats = list(map(str, formats)) media_stream.attributes = attributes # set attributes so that _codec_list is generated if content.description.encryption: if content.description.encryption.required: media_stream.transport = b'RTP/SAVP' for crypto in content.description.encryption.cryptos: crypto_str = '%s %s %s' % (crypto.tag, crypto.crypto_suite, crypto.key_params) if crypto.session_params: crypto_str += ' %s' % crypto.session_params media_stream.attributes.append(SDPAttribute(b'crypto', crypto_str.encode())) if isinstance(content.transport, jingle.IceUdpTransport): if content.transport.ufrag: media_stream.attributes.append(SDPAttribute(b'ice-ufrag', content.transport.ufrag.encode())) if content.transport.password: media_stream.attributes.append(SDPAttribute(b'ice-pwd', content.transport.password.encode())) for candidate in content.transport.candidates: if not ipv4_re.match(candidate.ip): continue candidate_str = '%s %d %s %d %s %d typ %s' % (candidate.foundation, candidate.component, candidate.protocol.upper(), candidate.priority, candidate.ip, candidate.port, candidate.typ) if candidate.related_addr and candidate.related_port: candidate_str += ' raddr %s rport %d' % (candidate.related_addr, candidate.related_port) media_stream.attributes.append(SDPAttribute(b'candidate', candidate_str.encode())) if content.transport.remote_candidate: remote_candidate = content.transport.remote_candidate remote_candidates_str = '%d %s %d' % (remote_candidate.component, remote_candidate.ip, remote_candidate.port) media_stream.attributes.append(SDPAttribute(b'remote-candidates', remote_candidates_str.encode())) elif isinstance(content.transport, jingle.RawUdpTransport): # Nothing to do here pass else: raise ValueError # Set the proper connection information, pick the first RTP candidate and use that try: candidate = next(c for c in content.transport.candidates if c.component == 1 and ipv4_re.match(c.ip)) except StopIteration: raise ValueError media_stream.connection = SDPConnection(candidate.ip.encode()) media_stream.port = candidate.port return media_stream def jingle_to_sdp(payload): sdp = SDPSession(b'127.0.0.1') stream_count = 0 for c in payload.content: try: media_stream = content_to_sdpstream(c) except ValueError as e: print('Error adding to SDP %s' % str(e)) continue stream_count += 1 sdp.media.append(media_stream) return sdp if stream_count > 0 else None ice_candidate_re = re.compile(r"""^(?P[a-zA-Z0-9+/]+) (?P\d+) (?P[a-zA-Z]+) (?P\d+) (?P[0-9a-fA-F.:]+) (?P\d+) typ (?P[a-zA-Z]+)(?: raddr (?P[0-9a-fA-F.:]+) rport (?P\d+))?$""", re.MULTILINE) crypto_re = re.compile(r"""^(?P\d+) (?P[a-zA-Z0-9\_]+) (?P[a-zA-Z0-9\:\+]+)(?: (?P[a-zA-Z0-9\:\+]+))?$""", re.MULTILINE) def sdpstream_to_content(sdp, index): media_stream = sdp.media[index] content = jingle.Content('initiator', media_stream.media) content.description = jingle.RTPDescription(media=media_stream.media) try: ptime = next(attr.value for attr in media_stream.attributes if attr.name=='ptime') except StopIteration: ptime = None try: maxptime = next(attr.value for attr in media_stream.attributes if attr.name=='maxptime') except StopIteration: maxptime = None rtp_mappings = media_stream.rtp_mappings.copy() MediaCodec = rtp_mappings[0].__class__ rtpmap_lines = '\n'.join(attr.value for attr in media_stream.attributes if attr.name=='rtpmap') rtpmap_codecs = dict([(int(type), MediaCodec(name, rate)) for type, name, rate in media_stream.rtpmap_re.findall(rtpmap_lines)]) rtp_mappings.update(rtpmap_codecs) for item in media_stream.formats: codec = rtp_mappings.get(int(item), None) if codec is not None: pt = jingle.PayloadType(int(item), codec.name, codec.rate, 1, ptime=ptime, maxptime=maxptime) for attr in (attr for attr in media_stream.attributes if attr.name=='fmtp' and attr.value.startswith(item)): value = attr.value.split(' ', 1)[1] for v in value.split(';'): fmtp_name, sep, fmtp_value = v.partition('=') pt.parameters.append(jingle.Parameter(fmtp_name, fmtp_value)) content.description.payloads.append(pt) content.description.encryption = jingle.Encryption(required=media_stream.transport=='RTP/SAVP') crypto_lines = '\n'.join(attr.value for attr in media_stream.attributes if attr.name=='crypto') for tag, suite, key_params, session_params in crypto_re.findall(crypto_lines): content.description.encryption.cryptos.append(jingle.Crypto(suite, key_params, tag, session_params)) if media_stream.has_ice_candidates: foundation_counter = count(1) - foundation_map = defaultdict(foundation_counter.next) + foundation_map = defaultdict(foundation_counter.__next__) id_counter = count(100) if not media_stream.has_ice_attributes and not sdp.has_ice_attributes: raise ValueError ufrag_attr = next(attr for attr in media_stream.attributes+sdp.attributes if attr.name=='ice-ufrag') pwd_attr = next(attr for attr in media_stream.attributes+sdp.attributes if attr.name=='ice-pwd') content.transport = jingle.IceUdpTransport(ufrag=ufrag_attr.value, pwd=pwd_attr.value) candidate_lines = '\n'.join(attr.value for attr in media_stream.attributes if attr.name=='candidate') for foundation, component, protocol, priority, ip, port, type, raddr, rport in ice_candidate_re.findall(candidate_lines): candidate = jingle.ICECandidate(component, foundation_map[foundation], 0, next(id_counter), ip, 0, port, priority, protocol.lower(), type, raddr or None, rport or None) content.transport.candidates.append(candidate) # TODO: translate remote-candidate else: content.transport = jingle.RawUdpTransport() connection = media_stream.connection or sdp.connection if not connection: raise ValueError content.transport.candidates.append(jingle.UDPCandidate(1, 0, 100, connection.address, media_stream.port, 'UDP')) for attr in media_stream.attributes: if attr.name == 'rtcp': content.transport.candidates.append(jingle.UDPCandidate(2, 0, 101, connection.address, attr.value, 'UDP')) break return content def sdp_to_jingle(sdp): payload = jingle.Jingle(None, None) # action and sid will be filled up by the session for index, media_stream in enumerate(sdp.media): try: content = sdpstream_to_content(sdp, index) except ValueError: continue payload.content.append(content) return payload diff --git a/sylk/applications/xmppgateway/xmpp/protocols.py b/sylk/applications/xmppgateway/xmpp/protocols.py index a361cbb..9fdaa15 100644 --- a/sylk/applications/xmppgateway/xmpp/protocols.py +++ b/sylk/applications/xmppgateway/xmpp/protocols.py @@ -1,387 +1,387 @@ from application.notification import NotificationCenter, NotificationData from twisted.internet import defer, reactor from twisted.words.protocols.jabber.error import StanzaError from twisted.words.protocols.jabber.jid import JID from wokkel import disco, muc, ping, xmppim from sylk.applications.xmppgateway.configuration import XMPPGatewayConfig from sylk.applications.xmppgateway.datatypes import Identity, FrozenURI from sylk.applications.xmppgateway.xmpp.stanzas import RECEIPTS_NS, CHATSTATES_NS, MUC_USER_NS, ErrorStanza, ChatComposingIndication from sylk.applications.xmppgateway.xmpp.stanzas import ChatMessage, GroupChatMessage, GroupChatSubject, IncomingInvitationMessage, NormalMessage, MessageReceipt from sylk.applications.xmppgateway.xmpp.stanzas import AvailabilityPresence, SubscriptionPresence, ProbePresence, MUCAvailabilityPresence from sylk.applications.xmppgateway.xmpp.stanzas import jingle __all__ = 'DiscoProtocol', 'JingleProtocol', 'MessageProtocol', 'MUCServerProtocol', 'MUCPresenceProtocol', 'PresenceProtocol' class MessageProtocol(xmppim.MessageProtocol): messageTypes = None, 'normal', 'chat', 'headline', 'groupchat', 'error' def _onMessage(self, message): if message.handled: return messageType = message.getAttribute("type") if messageType not in self.messageTypes: message["type"] = 'normal' self.onMessage(message) def onMessage(self, msg): notification_center = NotificationCenter() sender_uri = FrozenURI.parse('xmpp:'+msg['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+msg['to']) recipient = Identity(recipient_uri) msg_type = msg.getAttribute('type') msg_id = msg.getAttribute('id', None) is_empty = msg.body is None and msg.html is None if msg_type == 'error': error_type = msg.error['type'] conditions = [(child.name, child.defaultUri) for child in msg.error.elements()] error_message = ErrorStanza('message', sender, recipient, error_type, conditions, id=msg_id) notification_center.post_notification('XMPPGotErrorMessage', sender=self.parent, data=NotificationData(error_message=error_message)) return if msg_type in (None, 'normal', 'chat') and not is_empty: body = None html_body = None if msg.html is not None: html_body = msg.html.toXml() if msg.body is not None: - body = unicode(msg.body) + body = str(msg.body) try: elem = next(c for c in msg.elements() if c.uri == RECEIPTS_NS) except StopIteration: use_receipt = False else: - use_receipt = elem.name == u'request' + use_receipt = elem.name == 'request' if msg_type == 'chat': message = ChatMessage(sender, recipient, body, html_body, id=msg_id, use_receipt=use_receipt) notification_center.post_notification('XMPPGotChatMessage', sender=self.parent, data=NotificationData(message=message)) else: message = NormalMessage(sender, recipient, body, html_body, id=msg_id, use_receipt=use_receipt) notification_center.post_notification('XMPPGotNormalMessage', sender=self.parent, data=NotificationData(message=message)) return # Check if it's a composing indication if msg_type == 'chat' and is_empty: for elem in msg.elements(): try: elem = next(c for c in msg.elements() if c.uri == CHATSTATES_NS) except StopIteration: pass else: composing_indication = ChatComposingIndication(sender, recipient, elem.name, id=msg_id) notification_center.post_notification('XMPPGotComposingIndication', sender=self.parent, data=NotificationData(composing_indication=composing_indication)) return # Check if it's a receipt acknowledgement if is_empty: try: elem = next(c for c in msg.elements() if c.uri == RECEIPTS_NS) except StopIteration: pass else: - if elem.name == u'received' and msg_id is not None: + if elem.name == 'received' and msg_id is not None: receipt = MessageReceipt(sender, recipient, msg_id) notification_center.post_notification('XMPPGotReceipt', sender=self.parent, data=NotificationData(receipt=receipt)) class PresenceProtocol(xmppim.PresenceProtocol): def availableReceived(self, stanza): sender_uri = FrozenURI.parse('xmpp:'+stanza.element['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+stanza.element['to']) recipient = Identity(recipient_uri) id = stanza.element.getAttribute('id') show = stanza.show statuses = stanza.statuses presence_stanza = AvailabilityPresence(sender, recipient, available=True, show=show, statuses=statuses, id=id) NotificationCenter().post_notification('XMPPGotPresenceAvailability', sender=self.parent, data=NotificationData(presence_stanza=presence_stanza)) def unavailableReceived(self, stanza): sender_uri = FrozenURI.parse('xmpp:'+stanza.element['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+stanza.element['to']) recipient = Identity(recipient_uri) id = stanza.element.getAttribute('id') presence_stanza = AvailabilityPresence(sender, recipient, available=False, id=id) NotificationCenter().post_notification('XMPPGotPresenceAvailability', sender=self.parent, data=NotificationData(presence_stanza=presence_stanza)) def _process_subscription_stanza(self, stanza): sender_uri = FrozenURI.parse('xmpp:'+stanza.element['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+stanza.element['to']) recipient = Identity(recipient_uri) id = stanza.element.getAttribute('id') type = stanza.element.getAttribute('type') presence_stanza = SubscriptionPresence(sender, recipient, type, id=id) NotificationCenter().post_notification('XMPPGotPresenceSubscriptionStatus', sender=self.parent, data=NotificationData(presence_stanza=presence_stanza)) def subscribedReceived(self, stanza): self._process_subscription_stanza(stanza) def unsubscribedReceived(self, stanza): self._process_subscription_stanza(stanza) def subscribeReceived(self, stanza): self._process_subscription_stanza(stanza) def unsubscribeReceived(self, stanza): self._process_subscription_stanza(stanza) def probeReceived(self, stanza): sender_uri = FrozenURI.parse('xmpp:'+stanza.element['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+stanza.element['to']) recipient = Identity(recipient_uri) id = stanza.element.getAttribute('id') presence_stanza = ProbePresence(sender, recipient, id=id) NotificationCenter().post_notification('XMPPGotPresenceProbe', sender=self.parent, data=NotificationData(presence_stanza=presence_stanza)) class MUCServerProtocol(xmppim.BasePresenceProtocol): messageTypes = None, 'normal', 'chat', 'groupchat' presenceTypeParserMap = {'available': muc.UserPresence, 'unavailable': muc.UserPresence} def connectionInitialized(self): self.xmlstream.addObserver('/presence/x[@xmlns="%s"]' % muc.NS_MUC, self._onPresence) self.xmlstream.addObserver('/message', self._onMessage) def _onMessage(self, message): if message.handled: return messageType = message.getAttribute("type") if messageType == 'error': return if messageType not in self.messageTypes: message['type'] = 'normal' if messageType == 'groupchat': self.onGroupChat(message) else: to_uri = FrozenURI.parse('xmpp:'+message['to']) if to_uri.host in self.parent.domains: # Check if it's an invitation if message.x is not None and message.x.invite is not None and message.x.invite.uri == MUC_USER_NS: self.onInvitation(message) else: # TODO: give error, private messages not supported pass def onGroupChat(self, msg): sender_uri = FrozenURI.parse('xmpp:'+msg['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+msg['to']) recipient = Identity(recipient_uri) body = None html_body = None subject = msg.subject if msg.html is not None: html_body = msg.html.toXml() if msg.body is not None: - body = unicode(msg.body) + body = str(msg.body) if body or html_body: message = GroupChatMessage(sender, recipient, body, html_body, id=msg.getAttribute('id', None)) NotificationCenter().post_notification('XMPPMucGotGroupChat', sender=self.parent, data=NotificationData(message=message)) elif subject: message = GroupChatSubject(sender, recipient, subject, id=msg.getAttribute('id', None)) NotificationCenter().post_notification('XMPPMucGotSubject', sender=self.parent, data=NotificationData(message=message)) def onInvitation(self, msg): sender_uri = FrozenURI.parse('xmpp:'+msg['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+msg['to']) recipient = Identity(recipient_uri) invited_user_uri = FrozenURI.parse('xmpp:'+msg.x.invite['to']) invited_user = Identity(invited_user_uri) if msg.x.invite.reason is not None and msg.x.invite.reason.uri == MUC_USER_NS: - reason = unicode(msg.x.invite.reason) + reason = str(msg.x.invite.reason) else: reason = None invitation = IncomingInvitationMessage(sender, recipient, invited_user=invited_user, reason=reason, id=msg.getAttribute('id', None)) NotificationCenter().post_notification('XMPPMucGotInvitation', sender=self.parent, data=NotificationData(invitation=invitation)) def availableReceived(self, stanza): sender_uri = FrozenURI.parse('xmpp:'+stanza.element['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+stanza.element['to']) recipient = Identity(recipient_uri) id = stanza.element.getAttribute('id') presence_stanza = MUCAvailabilityPresence(sender, recipient, available=True, id=id) NotificationCenter().post_notification('XMPPMucGotPresenceAvailability', sender=self.parent, data=NotificationData(presence_stanza=presence_stanza)) def unavailableReceived(self, stanza): sender_uri = FrozenURI.parse('xmpp:'+stanza.element['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+stanza.element['to']) recipient = Identity(recipient_uri) id = stanza.element.getAttribute('id') presence_stanza = MUCAvailabilityPresence(sender, recipient, available=False, id=id) NotificationCenter().post_notification('XMPPMucGotPresenceAvailability', sender=self.parent, data=NotificationData(presence_stanza=presence_stanza)) class DiscoProtocol(disco.DiscoHandler): def info(self, requestor, target, nodeIdentifier): """ Gather data for a disco info request. @param requestor: The entity that sent the request. @type requestor: L{JID} @param target: The entity the request was sent to. @type target: L{JID} @param nodeIdentifier: The optional node being queried, or C{''}. @type nodeIdentifier: C{unicode} @return: Deferred with the gathered results from sibling handlers. @rtype: L{defer.Deferred} """ xmpp_manager = self.parent.manager if target.host not in xmpp_manager.domains | xmpp_manager.muc_domains: return defer.fail(StanzaError('service-unavailable')) elements = [disco.DiscoFeature(disco.NS_DISCO_INFO), disco.DiscoFeature(disco.NS_DISCO_ITEMS), disco.DiscoFeature('http://sylkserver.com')] if target.host in xmpp_manager.muc_domains: elements.append(disco.DiscoIdentity('conference', 'text', 'SylkServer Chat Service')) elements.append(disco.DiscoFeature('http://jabber.org/protocol/muc')) elements.append(disco.DiscoFeature('urn:ietf:rfc:3264')) elements.append(disco.DiscoFeature('urn:xmpp:coin')) elements.append(disco.DiscoFeature(jingle.NS_JINGLE)) elements.append(disco.DiscoFeature(jingle.NS_JINGLE_APPS_RTP)) elements.append(disco.DiscoFeature(jingle.NS_JINGLE_APPS_RTP_AUDIO)) #elements.append(disco.DiscoFeature(jingle.NS_JINGLE_APPS_RTP_VIDEO)) elements.append(disco.DiscoFeature(jingle.NS_JINGLE_ICE_UDP_TRANSPORT)) elements.append(disco.DiscoFeature(jingle.NS_JINGLE_RAW_UDP_TRANSPORT)) if target.user: # We can't say much more here, because the actual conference may end up on a different server elements.append(disco.DiscoFeature('muc_temporary')) elements.append(disco.DiscoFeature('muc_unmoderated')) else: elements.append(disco.DiscoFeature(ping.NS_PING)) if not target.user: elements.append(disco.DiscoIdentity('gateway', 'simple', 'SylkServer')) elements.append(disco.DiscoIdentity('server', 'im', 'SylkServer')) else: elements.append(disco.DiscoIdentity('client', 'pc')) elements.append(disco.DiscoFeature('http://jabber.org/protocol/caps')) elements.append(disco.DiscoFeature('http://jabber.org/protocol/chatstates')) elements.append(disco.DiscoFeature('urn:ietf:rfc:3264')) elements.append(disco.DiscoFeature('urn:xmpp:coin')) elements.append(disco.DiscoFeature(jingle.NS_JINGLE)) elements.append(disco.DiscoFeature(jingle.NS_JINGLE_APPS_RTP)) elements.append(disco.DiscoFeature(jingle.NS_JINGLE_APPS_RTP_AUDIO)) #elements.append(disco.DiscoFeature(jingle.NS_JINGLE_APPS_RTP_VIDEO)) elements.append(disco.DiscoFeature(jingle.NS_JINGLE_ICE_UDP_TRANSPORT)) elements.append(disco.DiscoFeature(jingle.NS_JINGLE_RAW_UDP_TRANSPORT)) return defer.succeed(elements) def items(self, requestor, target, nodeIdentifier): """ Gather data for a disco items request. @param requestor: The entity that sent the request. @type requestor: L{JID} @param target: The entity the request was sent to. @type target: L{JID} @param nodeIdentifier: The optional node being queried, or C{''}. @type nodeIdentifier: C{unicode} @return: Deferred with the gathered results from sibling handlers. @rtype: L{defer.Deferred} """ xmpp_manager = self.parent.manager items = [] if not target.user and target.host in xmpp_manager.domains: items.append(disco.DiscoItem(JID('%s.%s' % (XMPPGatewayConfig.muc_prefix, target.host)), name='Multi-User Chat')) return defer.succeed(items) class JingleProtocol(jingle.JingleHandler): # Functions here need to return immediately so that the IQ result is sent, so schedule them in the reactor # TODO: review and remove this, just post notifications? def onSessionInitiate(self, request): reactor.callLater(0, NotificationCenter().post_notification, 'XMPPGotJingleSessionInitiate', sender=self.parent, data=NotificationData(stanza=request, protocol=self)) def onSessionTerminate(self, request): reactor.callLater(0, NotificationCenter().post_notification, 'XMPPGotJingleSessionTerminate', sender=self.parent, data=NotificationData(stanza=request)) def onSessionAccept(self, request): reactor.callLater(0, NotificationCenter().post_notification, 'XMPPGotJingleSessionAccept', sender=self.parent, data=NotificationData(stanza=request)) def onSessionInfo(self, request): reactor.callLater(0, NotificationCenter().post_notification, 'XMPPGotJingleSessionInfo', sender=self.parent, data=NotificationData(stanza=request)) def onDescriptionInfo(self, request): reactor.callLater(0, NotificationCenter().post_notification, 'XMPPGotJingleDescriptionInfo', sender=self.parent, data=NotificationData(stanza=request)) def onTransportInfo(self, request): reactor.callLater(0, NotificationCenter().post_notification, 'XMPPGotJingleTransportInfo', sender=self.parent, data=NotificationData(stanza=request)) class MUCPresenceProtocol(xmppim.PresenceProtocol): """Protocol implementation to handle presence subscription to MUC URIs """ def subscribeReceived(self, stanza): """ Subscription request was received. """ self.subscribed(stanza.sender, sender=stanza.recipient) self.send_available(stanza) def unsubscribeReceived(self, stanza): """ Unsubscription request was received. """ self.unsubscribed(stanza.sender, sender=stanza.recipient) def probeReceived(self, stanza): """ Probe presence was received. """ self.send_available(stanza) def send_available(self, stanza): sender_uri = FrozenURI.parse('xmpp:'+stanza.element['from']) sender = Identity(sender_uri) recipient_uri = FrozenURI.parse('xmpp:'+stanza.element['to']) recipient = Identity(recipient_uri) available = AvailabilityPresence(sender=recipient, recipient=sender) self.send(available.to_xml_element()) diff --git a/sylk/applications/xmppgateway/xmpp/server.py b/sylk/applications/xmppgateway/xmpp/server.py index 28bc339..0be989d 100644 --- a/sylk/applications/xmppgateway/xmpp/server.py +++ b/sylk/applications/xmppgateway/xmpp/server.py @@ -1,116 +1,116 @@ from application.notification import NotificationCenter, NotificationData from twisted.internet import defer, reactor from twisted.words.protocols.jabber import error, xmlstream from twisted.words.protocols.jabber.jid import internJID from wokkel.component import InternalComponent, Router from wokkel.server import XMPPS2SServerFactory, DeferredS2SClientFactory __all__ = 'SylkRouter', 'SylkInternalComponent' class SylkInternalComponent(InternalComponent): def __init__(self, *args, **kwargs): InternalComponent.__init__(self, *args, **kwargs) self._iqDeferreds = {} def startService(self): InternalComponent.startService(self) self.xmlstream.addObserver('/iq[@type="result"]', self._onIQResponse) self.xmlstream.addObserver('/iq[@type="error"]', self._onIQResponse) def stopService(self): InternalComponent.stopService(self) iqDeferreds = self._iqDeferreds self._iqDeferreds = {} - for d in iqDeferreds.itervalues(): + for d in iqDeferreds.values(): d.errback(xmlstream.TimeoutError("Shutting down")) def request(self, request): if request.stanzaKind != 'iq' or request.stanzaType not in ('get', 'set'): return defer.fail(ValueError("Not a request")) element = request.toElement() # Make sure we have a trackable id on the stanza if not request.stanzaID: element.addUniqueId() request.stanzaID = element['id'] # Set up iq response tracking d = defer.Deferred() self._iqDeferreds[element['id']] = d timeout = getattr(request, 'timeout', None) if timeout is not None: def onTimeout(): del self._iqDeferreds[element['id']] d.errback(xmlstream.TimeoutError("IQ timed out")) call = reactor.callLater(timeout, onTimeout) def cancelTimeout(result): if call.active(): call.cancel() return result d.addBoth(cancelTimeout) self.send(element) return d def _onIQResponse(self, iq): try: d = self._iqDeferreds[iq["id"]] except KeyError: return del self._iqDeferreds[iq["id"]] iq.handled = True if iq['type'] == 'error': d.errback(error.exceptionFromStanza(iq)) else: d.callback(iq) class SylkRouter(Router): def route(self, stanza): """ Route a stanza. (subclassed to avoid vebose logging) @param stanza: The stanza to be routed. @type stanza: L{domish.Element}. """ destination = internJID(stanza['to']) if destination.host in self.routes: self.routes[destination.host].send(stanza) else: self.routes[None].send(stanza) class LoggingXMLStream(xmlstream.XmlStream): notification_center = NotificationCenter() def __init__(self, *args, **kw): xmlstream.XmlStream.__init__(self, *args, **kw) self.rawDataInFn = self._log_incoming_message self.rawDataOutFn = self._log_outgoing_message def _log_incoming_message(self, message): self.notification_center.post_notification('XMPPMessageTrace', sender=self, data=NotificationData(direction='INCOMING', message=message)) def _log_outgoing_message(self, message): self.notification_center.post_notification('XMPPMessageTrace', sender=self, data=NotificationData(direction='OUTGOING', message=message)) # Modify wokkel's factories to not be noisy and to use our logging protocol XMPPS2SServerFactory.noisy = False XMPPS2SServerFactory.protocol = LoggingXMLStream DeferredS2SClientFactory.noisy = False DeferredS2SClientFactory.protocol = LoggingXMLStream diff --git a/sylk/applications/xmppgateway/xmpp/session.py b/sylk/applications/xmppgateway/xmpp/session.py index 7828150..82cb7e3 100644 --- a/sylk/applications/xmppgateway/xmpp/session.py +++ b/sylk/applications/xmppgateway/xmpp/session.py @@ -1,217 +1,215 @@ from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.descriptor import WriteOnceAttribute from application.python.types import Singleton from eventlib import coros, proc from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.applications.xmppgateway.xmpp.stanzas import ChatMessage, ChatComposingIndication, MessageReceipt, ErrorStanza, GroupChatMessage, GroupChatSubject, MUCAvailabilityPresence __all__ = 'XMPPChatSession', 'XMPPChatSessionManager', 'XMPPIncomingMucSession', 'XMPPMucSessionManager' # Chat sessions class XMPPChatSession(object): local_identity = WriteOnceAttribute() remote_identity = WriteOnceAttribute() def __init__(self, local_identity, remote_identity): self.local_identity = local_identity self.remote_identity = remote_identity self.state = None self.pending_receipts = {} self.channel = coros.queue() self._proc = None from sylk.applications.xmppgateway.xmpp import XMPPManager self.xmpp_manager = XMPPManager() def start(self): NotificationCenter().post_notification('XMPPChatSessionDidStart', sender=self) self._proc = proc.spawn(self._run) self.state = 'started' def end(self): self.send_composing_indication('gone') self._clear_pending_receipts() self._proc.kill() self._proc = None NotificationCenter().post_notification('XMPPChatSessionDidEnd', sender=self, data=NotificationData(originator='local')) self.state = 'terminated' def send_message(self, body, html_body, message_id=None, use_receipt=True): message = ChatMessage(self.local_identity, self.remote_identity, body, html_body, id=message_id, use_receipt=use_receipt) self.xmpp_manager.send_stanza(message) if message_id is not None: timer = reactor.callLater(30, self._receipt_timer_expired, message_id) self.pending_receipts[message_id] = timer NotificationCenter().post_notification('XMPPChatSessionDidSendMessage', sender=self, data=NotificationData(message=message)) def send_composing_indication(self, state, message_id=None, use_receipt=False): message = ChatComposingIndication(self.local_identity, self.remote_identity, state, id=message_id, use_receipt=use_receipt) self.xmpp_manager.send_stanza(message) if message_id is not None: timer = reactor.callLater(30, self._receipt_timer_expired, message_id) self.pending_receipts[message_id] = timer NotificationCenter().post_notification('XMPPChatSessionDidSendMessage', sender=self, data=NotificationData(message=message)) def send_receipt_acknowledgement(self, receipt_id): message = MessageReceipt(self.local_identity, self.remote_identity, receipt_id) self.xmpp_manager.send_stanza(message) def send_error(self, stanza, error_type, conditions): message = ErrorStanza.from_stanza(stanza, error_type, conditions) self.xmpp_manager.send_stanza(message) def _run(self): notification_center = NotificationCenter() while True: item = self.channel.wait() if isinstance(item, ChatMessage): notification_center.post_notification('XMPPChatSessionGotMessage', sender=self, data=NotificationData(message=item)) elif isinstance(item, ChatComposingIndication): if item.state == 'gone': self._clear_pending_receipts() notification_center.post_notification('XMPPChatSessionDidEnd', sender=self, data=NotificationData(originator='remote')) self.state = 'terminated' break else: notification_center.post_notification('XMPPChatSessionGotComposingIndication', sender=self, data=NotificationData(message=item)) elif isinstance(item, MessageReceipt): if item.receipt_id in self.pending_receipts: timer = self.pending_receipts.pop(item.receipt_id) timer.cancel() notification_center.post_notification('XMPPChatSessionDidDeliverMessage', sender=self, data=NotificationData(message_id=item.receipt_id)) elif isinstance(item, ErrorStanza): if item.id in self.pending_receipts: timer = self.pending_receipts.pop(item.id) timer.cancel() # TODO: translate cause notification_center.post_notification('XMPPChatSessionDidNotDeliverMessage', sender=self, data=NotificationData(message_id=item.id, code=503, reason='Service Unavailable')) self._proc = None def _receipt_timer_expired(self, message_id): self.pending_receipts.pop(message_id) NotificationCenter().post_notification('XMPPChatSessionDidNotDeliverMessage', sender=self, data=NotificationData(message_id=message_id, code=408, reason='Timeout')) def _clear_pending_receipts(self): notification_center = NotificationCenter() while self.pending_receipts: message_id, timer = self.pending_receipts.popitem() timer.cancel() notification_center.post_notification('XMPPChatSessionDidNotDeliverMessage', sender=self, data=NotificationData(message_id=message_id, code=408, reason='Timeout')) -class XMPPChatSessionManager(object): - __metaclass__ = Singleton - implements(IObserver) +@implementer(IObserver) +class XMPPChatSessionManager(object, metaclass=Singleton): def __init__(self): self.sessions = {} def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, name='XMPPChatSessionDidStart') notification_center.add_observer(self, name='XMPPChatSessionDidEnd') def stop(self): notification_center = NotificationCenter() notification_center.remove_observer(self, name='XMPPChatSessionDidStart') notification_center.remove_observer(self, name='XMPPChatSessionDidEnd') def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_XMPPChatSessionDidStart(self, notification): session = notification.sender self.sessions[(session.local_identity.uri, session.remote_identity.uri)] = session def _NH_XMPPChatSessionDidEnd(self, notification): session = notification.sender del self.sessions[(session.local_identity.uri, session.remote_identity.uri)] # MUC sessions class XMPPIncomingMucSession(object): local_identity = WriteOnceAttribute() remote_identity = WriteOnceAttribute() def __init__(self, local_identity, remote_identity): self.local_identity = local_identity self.remote_identity = remote_identity self.state = None self.channel = coros.queue() self._proc = None from sylk.applications.xmppgateway.xmpp import XMPPManager self.xmpp_manager = XMPPManager() def start(self): NotificationCenter().post_notification('XMPPIncomingMucSessionDidStart', sender=self) self._proc = proc.spawn(self._run) self.state = 'started' def end(self): self._proc.kill() self._proc = None NotificationCenter().post_notification('XMPPIncomingMucSessionDidEnd', sender=self, data=NotificationData(originator='local')) self.state = 'terminated' def send_message(self, sender, body, html_body, message_id=None): # TODO: timestamp? message = GroupChatMessage(sender, self.remote_identity, body, html_body, id=message_id) self.xmpp_manager.send_muc_stanza(message) def _run(self): notification_center = NotificationCenter() while True: item = self.channel.wait() if isinstance(item, GroupChatMessage): notification_center.post_notification('XMPPIncomingMucSessionGotMessage', sender=self, data=NotificationData(message=item)) elif isinstance(item, GroupChatSubject): notification_center.post_notification('XMPPIncomingMucSessionSubject', sender=self, data=NotificationData(message=item)) elif isinstance(item, MUCAvailabilityPresence): if item.available: nickname = item.recipient.uri.resource notification_center.post_notification('XMPPIncomingMucSessionChangedNickname', sender=self, data=NotificationData(stanza=item, nickname=nickname)) else: notification_center.post_notification('XMPPIncomingMucSessionDidEnd', sender=self, data=NotificationData(originator='local')) self.state = 'terminated' break self._proc = None -class XMPPMucSessionManager(object): - __metaclass__ = Singleton - implements(IObserver) +@implementer(IObserver) +class XMPPMucSessionManager(object, metaclass=Singleton): def __init__(self): self.incoming = {} self.outgoing = {} def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, name='XMPPIncomingMucSessionDidStart') notification_center.add_observer(self, name='XMPPIncomingMucSessionDidEnd') def stop(self): notification_center = NotificationCenter() notification_center.remove_observer(self, name='XMPPIncomingMucSessionDidStart') notification_center.remove_observer(self, name='XMPPIncomingMucSessionDidEnd') def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_XMPPIncomingMucSessionDidStart(self, notification): muc = notification.sender self.incoming[(muc.local_identity.uri, muc.remote_identity.uri)] = muc def _NH_XMPPIncomingMucSessionDidEnd(self, notification): muc = notification.sender del self.incoming[(muc.local_identity.uri, muc.remote_identity.uri)] diff --git a/sylk/applications/xmppgateway/xmpp/stanzas/__init__.py b/sylk/applications/xmppgateway/xmpp/stanzas/__init__.py index c70bd8a..6a69c85 100644 --- a/sylk/applications/xmppgateway/xmpp/stanzas/__init__.py +++ b/sylk/applications/xmppgateway/xmpp/stanzas/__init__.py @@ -1,295 +1,295 @@ import hashlib from twisted.words.xish import domish from sylk import __version__ as SYLK_VERSION from sylk.applications.xmppgateway.util import html2text CHATSTATES_NS = 'http://jabber.org/protocol/chatstates' RECEIPTS_NS = 'urn:xmpp:receipts' STANZAS_NS = 'urn:ietf:params:xml:ns:xmpp-stanzas' XML_NS = 'http://www.w3.org/XML/1998/namespace' MUC_NS = 'http://jabber.org/protocol/muc' MUC_USER_NS = MUC_NS + '#user' CAPS_NS = 'http://jabber.org/protocol/caps' -SYLK_HASH = hashlib.sha1('SylkServer-%s' % SYLK_VERSION).hexdigest() +SYLK_HASH = hashlib.sha1(('SylkServer-%s' % SYLK_VERSION).encode()).hexdigest() SYLK_CAPS = [] class BaseStanza(object): stanza_type = None # to be defined by subclasses type = None def __init__(self, sender, recipient, id=None): self.sender = sender self.recipient = recipient self.id = id def to_xml_element(self): xml_element = domish.Element((None, self.stanza_type)) - xml_element['from'] = unicode(self.sender.uri.as_xmpp_jid()) - xml_element['to'] = unicode(self.recipient.uri.as_xmpp_jid()) + xml_element['from'] = str(self.sender.uri.as_xmpp_jid()) + xml_element['to'] = str(self.recipient.uri.as_xmpp_jid()) if self.type: xml_element['type'] = self.type if self.id is not None: xml_element['id'] = self.id return xml_element class ErrorStanza(object): """ Stanza representing an error of another stanza. It's not a base stanza type on its own. """ def __init__(self, stanza_type, sender, recipient, error_type, conditions, id=None): self.stanza_type = stanza_type self.sender = sender self.recipient = recipient self.id = id self.conditions = conditions self.error_type = error_type @classmethod def from_stanza(cls, stanza, error_type, conditions): # In error stanzas sender and recipient are swapped return cls(stanza.stanza_type, stanza.recipient, stanza.sender, error_type, conditions, id=stanza.id) def to_xml_element(self): xml_element = domish.Element((None, self.stanza_type)) - xml_element['from'] = unicode(self.sender.uri.as_xmpp_jid()) - xml_element['to'] = unicode(self.recipient.uri.as_xmpp_jid()) + xml_element['from'] = str(self.sender.uri.as_xmpp_jid()) + xml_element['to'] = str(self.recipient.uri.as_xmpp_jid()) xml_element['type'] = 'error' if self.id is not None: xml_element['id'] = self.id error_element = domish.Element((None, 'error')) error_element['type'] = self.error_type [error_element.addChild(domish.Element((ns, condition))) for condition, ns in self.conditions] xml_element.addChild(error_element) return xml_element class BaseMessageStanza(BaseStanza): stanza_type = 'message' def __init__(self, sender, recipient, body=None, html_body=None, id=None, use_receipt=False): super(BaseMessageStanza, self).__init__(sender, recipient, id=id) self.use_receipt = use_receipt if body is not None and html_body is None: self.body = body self.html_body = None elif body is None and html_body is not None: self.body = html2text(html_body) self.html_body = html_body else: self.body = body self.html_body = html_body def to_xml_element(self): xml_element = super(BaseMessageStanza, self).to_xml_element() if self.id is not None and self.recipient.uri.resource is not None and self.use_receipt: xml_element.addElement('request', defaultUri=RECEIPTS_NS) if self.body is not None: xml_element.addElement('body', content=self.body) if self.html_body is not None: xml_element.addElement('html', content=self.html_body) return xml_element class NormalMessage(BaseMessageStanza): def __init__(self, sender, recipient, body=None, html_body=None, id=None, use_receipt=False): if body is None and html_body is None: raise ValueError('either body or html_body need to be set') super(NormalMessage, self).__init__(sender, recipient, body, html_body, id, use_receipt) class ChatMessage(BaseMessageStanza): type = 'chat' def __init__(self, sender, recipient, body=None, html_body=None, id=None, use_receipt=True): if body is None and html_body is None: raise ValueError('either body or html_body need to be set') super(ChatMessage, self).__init__(sender, recipient, body, html_body, id, use_receipt) def to_xml_element(self): xml_element = super(ChatMessage, self).to_xml_element() xml_element.addElement('active', defaultUri=CHATSTATES_NS) return xml_element class ChatComposingIndication(BaseMessageStanza): type = 'chat' def __init__(self, sender, recipient, state, id=None, use_receipt=False): super(ChatComposingIndication, self).__init__(sender, recipient, id=id, use_receipt=use_receipt) self.state = state def to_xml_element(self): xml_element = super(ChatComposingIndication, self).to_xml_element() xml_element.addElement(self.state, defaultUri=CHATSTATES_NS) return xml_element class GroupChatMessage(BaseMessageStanza): type = 'groupchat' def __init__(self, sender, recipient, body=None, html_body=None, id=None): # TODO: add timestamp if body is None and html_body is None: raise ValueError('either body or html_body need to be set') super(GroupChatMessage, self).__init__(sender, recipient, body, html_body, id, False) class GroupChatSubject(BaseMessageStanza): type = 'groupchat' # TODO: add delay def __init__(self, sender, recipient, subject, id=None): # TODO: add timestamp if subject is None: raise ValueError('subject need to be set') super(GroupChatSubject, self).__init__(sender, recipient, subject, None, id, False) class MessageReceipt(BaseMessageStanza): def __init__(self, sender, recipient, receipt_id, id=None): super(MessageReceipt, self).__init__(sender, recipient, id=id, use_receipt=False) self.receipt_id = receipt_id def to_xml_element(self): xml_element = super(MessageReceipt, self).to_xml_element() receipt_element = domish.Element((RECEIPTS_NS, 'received')) receipt_element['id'] = self.receipt_id xml_element.addChild(receipt_element) return xml_element class BasePresenceStanza(BaseStanza): stanza_type = 'presence' class IncomingInvitationMessage(BaseMessageStanza): def __init__(self, sender, recipient, invited_user, reason=None, id=None): super(IncomingInvitationMessage, self).__init__(sender, recipient, body=None, html_body=None, id=id, use_receipt=False) self.invited_user = invited_user self.reason = reason def to_xml_element(self): xml_element = super(IncomingInvitationMessage, self).to_xml_element() child = xml_element.addElement((MUC_USER_NS, 'x')) child.addElement('invite') - child.invite['to'] = unicode(self.invited_user.uri.as_xmpp_jid()) + child.invite['to'] = str(self.invited_user.uri.as_xmpp_jid()) if self.reason: child.invite.addElement('reason', content=self.reason) return xml_element class OutgoingInvitationMessage(BaseMessageStanza): def __init__(self, sender, recipient, originator, reason=None, id=None): super(OutgoingInvitationMessage, self).__init__(sender, recipient, body=None, html_body=None, id=id, use_receipt=False) self.originator = originator self.reason = reason def to_xml_element(self): xml_element = super(OutgoingInvitationMessage, self).to_xml_element() child = xml_element.addElement((MUC_USER_NS, 'x')) child.addElement('invite') - child.invite['from'] = unicode(self.originator.uri.as_xmpp_jid()) + child.invite['from'] = str(self.originator.uri.as_xmpp_jid()) if self.reason: child.invite.addElement('reason', content=self.reason) return xml_element class AvailabilityPresence(BasePresenceStanza): def __init__(self, sender, recipient, available=True, show=None, statuses=None, priority=0, id=None): super(AvailabilityPresence, self).__init__(sender, recipient, id=id) self.available = available self.show = show self.priority = priority self.statuses = statuses or {} @property def available(self): return self.__dict__['available'] @available.setter def available(self, available): if available: self.type = None else: self.type = 'unavailable' self.__dict__['available'] = available @property def status(self): status = self.statuses.get(None) if status is None: try: - status = next(self.statuses.itervalues()) + status = next(iter(self.statuses.values())) except StopIteration: pass return status def to_xml_element(self): xml_element = super(BasePresenceStanza, self).to_xml_element() if self.available: if self.show is not None: xml_element.addElement('show', content=self.show) if self.priority != 0: - xml_element.addElement('priority', content=unicode(self.priority)) + xml_element.addElement('priority', content=str(self.priority)) caps = xml_element.addElement('c', defaultUri=CAPS_NS) caps['node'] = 'https://sylkserver.com' caps['hash'] = 'sha-1' caps['ver'] = SYLK_HASH if SYLK_CAPS: caps['ext'] = ' '.join(SYLK_CAPS) - for lang, text in self.statuses.iteritems(): + for lang, text in self.statuses.items(): status = xml_element.addElement('status', content=text) if lang: status[(XML_NS, 'lang')] = lang return xml_element class SubscriptionPresence(BasePresenceStanza): def __init__(self, sender, recipient, type, id=None): super(SubscriptionPresence, self).__init__(sender, recipient, id=id) self.type = type class ProbePresence(BasePresenceStanza): type = 'probe' class MUCAvailabilityPresence(AvailabilityPresence): def __init__(self, sender, recipient, available=True, show=None, statuses=None, priority=0, id=None, affiliation=None, jid=None, role=None, muc_statuses=None): super(MUCAvailabilityPresence, self).__init__(sender, recipient, available, show, statuses, priority, id) self.affiliation = affiliation or 'member' self.role = role or 'participant' self.muc_statuses = muc_statuses or [] self.jid = jid def to_xml_element(self): xml_element = super(MUCAvailabilityPresence, self).to_xml_element() muc = xml_element.addElement('x', defaultUri=MUC_USER_NS) item = muc.addElement('item') if self.affiliation: item['affiliation'] = self.affiliation if self.role: item['role'] = self.role if self.jid: - item['jid'] = unicode(self.jid.uri.as_xmpp_jid()) + item['jid'] = str(self.jid.uri.as_xmpp_jid()) for code in self.muc_statuses: status = muc.addElement('status') status['code'] = code return xml_element class MUCErrorPresence(ErrorStanza): def to_xml_element(self): xml_element = super(MUCErrorPresence, self).to_xml_element() xml_element.addElement('x', defaultUri=MUC_USER_NS) return xml_element diff --git a/sylk/applications/xmppgateway/xmpp/stanzas/jingle.py b/sylk/applications/xmppgateway/xmpp/stanzas/jingle.py index 3c7a133..91dca78 100644 --- a/sylk/applications/xmppgateway/xmpp/stanzas/jingle.py +++ b/sylk/applications/xmppgateway/xmpp/stanzas/jingle.py @@ -1,693 +1,693 @@ # Copyright (c) AG Projects # Copyright (c) Uday Verma # Copyright (c) Ralph Meijer. # """ XMPP Jingle Protocol. This protocol is specified in * XEP-0166 - http://xmpp.org/extensions/xep-0166.html * XEP-0167 - http://xmpp.org/extensions/xep-0167.html * XEP-0176 - http://xmpp.org/extensions/xep-0176.html * XEP-0177 - http://xmpp.org/extensions/xep-0177.html """ from twisted.words.xish import domish from twisted.words.protocols.jabber import error from wokkel.generic import Request from wokkel.subprotocols import IQHandlerMixin, XMPPHandler NS_JINGLE_BASE = 'urn:xmpp:jingle' NS_JINGLE = NS_JINGLE_BASE + ':1' NS_JINGLE_ERRORS = NS_JINGLE_BASE + ':errors:1' NS_JINGLE_APPS_BASE = NS_JINGLE_BASE + ':apps' NS_JINGLE_APPS_RTP = NS_JINGLE_APPS_BASE + ':rtp:1' NS_JINGLE_APPS_RTP_INFO = NS_JINGLE_APPS_BASE + ':rtp:info:1' NS_JINGLE_APPS_RTP_AUDIO = NS_JINGLE_APPS_BASE + ':rtp:audio' NS_JINGLE_APPS_RTP_VIDEO = NS_JINGLE_APPS_BASE + ':rtp:video' NS_JINGLE_APPS_COIN = NS_JINGLE_APPS_BASE + ':coin:1' NS_JINGLE_ICE_UDP_TRANSPORT = NS_JINGLE_BASE + ':transports:ice-udp:1' NS_JINGLE_RAW_UDP_TRANSPORT = NS_JINGLE_BASE + ':transports:raw-udp:1' # XPath for Jingle IQ requests IQ_JINGLE_REQUEST = '/iq[@type="get" or @type="set"]/jingle[@xmlns="' + NS_JINGLE + '"]' class Parameter(object): """ A class representing a payload parameter """ def __init__(self, name, value): self.name, self.value = name, value @classmethod def fromElement(cls, element): return cls(element.getAttribute('name'), element.getAttribute('value')) def toElement(self, defaultUri=None): element = domish.Element((defaultUri, 'parameter')) element['name'] = self.name element['value'] = self.value or '' return element class Crypto(object): """ A crypto method which makes up the encryption to be used """ def __init__(self, crypto_suite, key_params, tag, session_params=None): self.crypto_suite, self.key_params, self.tag, self.session_params = crypto_suite, key_params, tag, session_params @classmethod def fromElement(cls, element): return cls(element.getAttribute('crypto-suite'), element.getAttribute('key-params'), element.getAttribute('tag'), element.getAttribute('session-params')) def toElement(self, defaultUri=None): element = domish.Element((defaultUri, 'crypto')) element['crypto-suite'] = self.crypto_suite element['key-params'] = self.key_params if self.session_params: element['session-params'] = self.session_params element['tag'] = self.tag return element class Encryption(object): """ A class representing encryption method """ def __init__(self, required=False, cryptos=None): self.required, self.cryptos = required, cryptos or [] @classmethod def fromElement(cls, element): cryptos = [] for child in element.elements(): if child.name == 'crypto': cryptos.append(Crypto.fromElement(child)) # TODO: parse ZRTP elements required = element.hasAttribute('required') and (element.getAttribute('required').lower() in ['true', '1']) return cls(required, cryptos) def toElement(self, defaultUri=None): element = domish.Element((defaultUri, 'encryption')) if self.required: element['required'] = '1' for c in self.cryptos: element.addChild(c.toElement(defaultUri)) return element class Bandwidth(object): """ A class representing the bandwidth element """ def __init__(self, typ, value): self.typ, self.value = typ, value @classmethod def fromElement(cls, element): return cls(element.getAttribute('type'), str(element)) def toElement(self, defaultUri=None): element = domish.Element((defaultUri, 'bandwidth')) element['type'] = self.typ element.addContent(self.value) return element class PayloadType(object): """ A class representing payload type """ def __init__(self, id, name, clockrate=0, channels=0, maxptime=None, ptime=None, parameters=None): self.id, self.name, self.clockrate, self.channels, \ self.maxptime, self.ptime, self.parameters = \ id, name, clockrate, channels, maxptime, ptime, parameters or [] @classmethod def fromElement(cls, element): def _sga(v, t): """ SafeGetAttribute """ try: return t(element.getAttribute(v)) except (TypeError, ValueError): return None params = [] for c in element.children: params.append(Parameter.fromElement(c)) return cls(int(element.getAttribute('id')), element.getAttribute('name'), _sga('clockrate', int) or 0, _sga('channels', int) or 0, _sga('maxptime', int) or 0, _sga('ptime', int) or 0, params) def toElement(self, defaultUri=None): element = domish.Element((defaultUri, 'payload-type')) def _aiv(k, v): """ AppendIfValid """ if v: element[k] = str(v) element['id'] = str(self.id) _aiv('name', self.name) _aiv('clockrate', self.clockrate) _aiv('channels', self.channels) _aiv('maxptime', self.maxptime) _aiv('ptime', self.ptime) for p in self.parameters: element.addChild(p.toElement()) return element class ICECandidate(object): """ A class representing an ICE candidate """ def __init__(self, component, foundation, generation, id, ip, network, port, priority, protocol, typ, related_addr=None, related_port=0): self.component, self.foundation, self.generation, \ self.id, self.ip, self.network, self.port, self.priority, \ self.protocol, self.typ, self.related_addr, self.related_port = \ component, foundation, generation, \ id, ip, network, port, priority, protocol, typ, \ related_addr, related_port @classmethod def fromElement(cls, element): def _gas(*names): """ GetAttributeS """ def default_val(t): return None if t is str else t() return [(t(element.getAttribute(name)) if element.hasAttribute(name) else default_val(t)) for name, t in names] return cls(*_gas(('component', int), ('foundation', int), ('generation', int), ('id', str), ('ip', str), ('network', int), ('port', int), ('priority', int), ('protocol', str), ('type', str), ('rel-addr', str), ('rel-port', int))) def toElement(self, defaultUri=None): element = domish.Element((defaultUri, 'candidate')) def _aas(*names): """ AddAttributeS """ for n, v in names: if v is not None: element[n] = str(v) _aas(*[('component', self.component), ('foundation', self.foundation), ('generation', self.generation), ('id', self.id), ('ip', self.ip), ('network', self.network), ('port', self.port), ('priority', self.priority), ('protocol', self.protocol), ('type', self.typ), ('rel-addr', self.related_addr), ('rel-port', self.related_port)]) return element class UDPCandidate(object): """ A class representing a UDP candidate """ def __init__(self, component, generation, id_, ip, port, protocol, type=None): self.component = component self.generation = generation self.id = id_ self.ip = ip self.port = port self.protocol = protocol self.type = type @classmethod def fromElement(cls, element): def _gas(*names): """ GetAttributeS """ def default_val(t): return None if t is str else t() return [(t(element.getAttribute(name)) if element.hasAttribute(name) else default_val(t)) for name, t in names] return cls(*_gas(('component', int), ('generation', int), ('id', str), ('ip', str), ('port', int), ('protocol', str), ('type', str))) def toElement(self, defaultUri=None): element = domish.Element((defaultUri, 'candidate')) def _aas(*names): """ AddAttributeS """ for n, v in names: if v: element[n] = str(v) _aas(*[('component', self.component), ('generation', self.generation), ('id', self.id), ('ip', self.ip), ('port', self.port), ('protocol', self.protocol), ('type', self.type)]) return element class ICERemoteCandidate(object): """ A class represeting a remote candidate entity """ def __init__(self, component, ip, port): self.component, self.ip, self.port = component, ip, port @classmethod def fromElement(cls, element): return cls(int(element.getAttribute('component') or '0'), element.getAttribute('ip'), int(element.getAttribute('port') or '0')) def toElement(self, defaultUri=None): element = domish.Element((defaultUri, 'remote-candidate')) element['component'] = str(self.component) element['ip'] = self.ip element['port'] = str(self.port) return element class IceUdpTransport(object): """ Represents the ICE-UDP transport type """ def __init__(self, pwd=None, ufrag=None, candidates=None, remote_candidate=None): self.password, self.ufrag, self.candidates, self.remote_candidate = \ pwd, ufrag, candidates or [], remote_candidate @classmethod def fromElement(cls, element): password = element.getAttribute('pwd') or None ufrag = element.getAttribute('ufrag') or None candidates = [] remote_candidate = None for child in element.elements(): if child.name == 'remote-candidate' and remote_candidate is None: remote_candidate = ICERemoteCandidate.fromElement(child) elif child.name == 'candidate': candidates.append(ICECandidate.fromElement(child)) return cls(pwd=password, ufrag=ufrag, candidates=candidates, remote_candidate=remote_candidate) def toElement(self, defaultUri=None): element = domish.Element((defaultUri or NS_JINGLE_ICE_UDP_TRANSPORT, 'transport')) if self.password: element['pwd'] = self.password if self.ufrag: element['ufrag'] = self.ufrag if self.remote_candidate: element.addChild(self.remote_candidate.toElement()) elif self.candidates: for c in self.candidates: element.addChild(c.toElement()) return element class RawUdpTransport(object): """ Represents the Raw-UDP transport type """ def __init__(self, candidates=None): self.candidates = candidates or [] @classmethod def fromElement(cls, element): candidates = [] for child in element.elements(): if child.name == 'candidate': candidates.append(UDPCandidate.fromElement(child)) return cls(candidates=candidates) def toElement(self, defaultUri=None): element = domish.Element((defaultUri or NS_JINGLE_RAW_UDP_TRANSPORT, 'transport')) for c in self.candidates: element.addChild(c.toElement()) return element class RTPDescription(object): """ A class representing a RTP description """ def __init__(self, name=None, media=None, ssrc=None, payloads=None, encryption=None, bandwidth=None): self.name, self.media, self.ssrc, self.payloads, \ self.encryption, self.bandwidth = \ name, media, ssrc, payloads or [], encryption, bandwidth @classmethod def fromElement(cls, element): plds = [] encryption, bandwidth = None, None for child in element.elements(): if child.name == 'payload-type': plds.append(PayloadType.fromElement(child)) if child.name == 'encryption': encryption = Encryption.fromElement(child) if child.name == 'bandwidth': bandwidth = Bandwidth.fromElement(child) return cls(element.getAttribute('name'), element.getAttribute('media'), element.getAttribute('ssrc'), plds, encryption, bandwidth) def toElement(self, defaultUri=None): element = domish.Element((defaultUri or NS_JINGLE_APPS_RTP, 'description')) if self.name: element['name'] = self.name if self.media: element['media'] = self.media for p in self.payloads: element.addChild(p.toElement(defaultUri)) if self.encryption: element.addChild(self.encryption.toElement(defaultUri)) if self.bandwidth: element.addChild(self.bandwidth.toElement(defaultUri)) return element class Content(object): """ A class indicating a single content item within a jingle request. """ def __init__(self, creator, name, disposition=None, senders=None): self.creator, self.name, self.disposition, self.senders = \ creator, name, disposition, senders self.description = None self.transport = None @classmethod def fromElement(cls, element): creator = element.getAttribute('creator') name = element.getAttribute('name') disposition = element.getAttribute('disposition') senders = element.getAttribute('senders') description, transport = None, None for c in element.elements(): if c.name == 'description' and c.uri == NS_JINGLE_APPS_RTP: description = RTPDescription.fromElement(c) elif c.name == 'transport' and c.uri == NS_JINGLE_ICE_UDP_TRANSPORT: transport = IceUdpTransport.fromElement(c) elif c.name == 'transport' and c.uri == NS_JINGLE_RAW_UDP_TRANSPORT: transport = RawUdpTransport.fromElement(c) ret = cls(creator, name, disposition, senders) ret.description = description ret.transport = transport return ret def toElement(self): element = domish.Element((None, 'content')) element['creator'] = self.creator element['name'] = self.name if self.disposition: element['disposition'] = self.disposition if self.senders: element['senders'] = self.senders if self.description: element.addChild(self.description.toElement()) if self.transport: element.addChild(self.transport.toElement()) return element -class EmptyType(unicode): +class EmptyType(str): @classmethod def fromElement(cls, element): return cls(element.name) def toElement(self): return domish.Element((None, self)) class ReasonType(EmptyType): pass -class AlternativeSessionReason(unicode): +class AlternativeSessionReason(str): def __new__(cls, value): - obj = unicode.__new__(cls, 'alternative-session') + obj = str.__new__(cls, 'alternative-session') obj.sid = value return obj @classmethod def fromElement(cls, element): return cls(element.firstChildElement().children[0]) def toElement(self): element = domish.Element((None, self)) element.addElement('sid', content=self.sid) return element class Reason(object): def __init__(self, reason, text=None): self.value = reason self.text = text @classmethod def fromElement(cls, element): reason = None text = None for c in element.children: if c.name == 'text': text = c.children[0] elif c.name == 'alternative-session': reason = AlternativeSessionReason.fromElement(c) else: reason = ReasonType.fromElement(c) return cls(reason, text) def toElement(self): element = domish.Element((None, 'reason')) element.addChild(self.value.toElement()) if self.text: element.addElement('text', content=self.text) return element -class Info(unicode): +class Info(str): @classmethod def fromElement(cls, element): return cls(element.name) def toElement(self): return domish.Element((NS_JINGLE_APPS_RTP_INFO, self)) class MuteInfo(Info): def __new__(cls, value, creator, name): - obj = unicode.__new__(cls, value) + obj = str.__new__(cls, value) obj.creator = creator obj.name = name return obj @classmethod def fromElement(cls, element): return cls(element.name, element['creator'], element['name']) def toElement(self): element = super(MuteInfo, self).toElement() element['creator'] = self.creator element['name'] = self.name return element class ConferenceInfo(object): def __init__(self, isfocus): self.isfocus = isfocus @classmethod def fromElement(cls, element): return cls(element.getAttribute('isfocus')) def toElement(self, defaultUri=None): #element = domish.Element((defaultUri or NS_JINGLE_APPS_COIN, 'conference-info')) # TODO: Jitsi sends an empty string here, lets do the same until they fix it element = domish.Element(("", 'conference-info')) element['isfocus'] = 'true' if self.isfocus else 'false' return element class Jingle(object): """ A class representing a Jingle element within an IQ request """ def __init__(self, action, sid, initiator=None, responder=None, content=None, reason=None, info=None, conference_info=None): self.action = action self.sid = sid self.initiator = initiator self.responder = responder self.reason = reason self.info = info if not hasattr(content, '__iter__'): if content is not None: self.content = [content] else: self.content = [] else: self.content = content self.conference_info = conference_info @classmethod def fromElement(cls, element): action = element.getAttribute('action') initiator = element.getAttribute('initiator') responder = element.getAttribute('responder') sid = element.getAttribute('sid') content = [] reason = None info = None conference_info = None for c in element.elements(): if c.name == 'content': content.append(Content.fromElement(c)) elif c.name == 'reason': reason = Reason.fromElement(c) elif c.uri == NS_JINGLE_APPS_RTP_INFO: if c.name in ('mute', 'unmute'): info = MuteInfo.fromElement(c) else: info = Info.fromElement(c) elif c.name == 'conference-info' and c.uri == NS_JINGLE_APPS_COIN: conference_info = ConferenceInfo.fromElement(c) return cls(action, sid, initiator, responder, content=content, reason=reason, info=info, conference_info=conference_info) def toElement(self): element = domish.Element((NS_JINGLE, 'jingle')) element['action'] = self.action element['sid'] = self.sid if self.initiator: element['initiator'] = self.initiator if self.responder: element['responder'] = self.responder for c in self.content: element.addChild(c.toElement()) if self.reason: element.addChild(self.reason.toElement()) if self.info: element.addChild(self.info.toElement()) if self.conference_info: element.addChild(self.conference_info.toElement()) return element class JingleIq(Request): stanzaKind = 'iq' stanzaType = 'set' timeout = None childParsers = {(NS_JINGLE, 'jingle'): '_parseJingleElement'} def __init__(self, sender=None, recipient=None, jingle=None): Request.__init__(self, recipient, sender, self.stanzaType) self.jingle = jingle def _parseJingleElement(self, element): self.jingle = Jingle.fromElement(element) def toElement(self): element = Request.toElement(self) element.addChild(self.jingle.toElement()) return element class ConferenceInfoIq(Request): stanzaKind = 'iq' stanzaType = 'set' timeout = None def __init__(self, sender=None, recipient=None, payload=None): if not payload: raise ValueError('conference info payload cannot be empty') Request.__init__(self, recipient, sender, self.stanzaType) self.payload = payload def toElement(self): element = Request.toElement(self) element.addRawXml(self.payload) return element class JingleHandler(XMPPHandler, IQHandlerMixin): iqHandlers = {IQ_JINGLE_REQUEST: '_onJingleRequest'} def connectionInitialized(self): self.xmlstream.addObserver(IQ_JINGLE_REQUEST, self.handleRequest) def sessionTerminate(self, sender, recipient, sid, reason=None): jingle = Jingle('session-terminate', sid, reason=reason) return JingleIq(sender=sender, recipient=recipient, jingle=jingle) def sessionInfo(self, sender, recipient, sid, info=None): jingle = Jingle('session-info', sid, info=info) return JingleIq(sender=sender, recipient=recipient, jingle=jingle) def sessionAccept(self, sender, recipient, payload): payload.action = 'session-accept' return JingleIq(sender=sender, recipient=recipient, jingle=payload) def sessionInitiate(self, sender, recipient, payload): payload.action = 'session-initiate' return JingleIq(sender=sender, recipient=recipient, jingle=payload) def _onJingleRequest(self, iq): request = JingleIq.fromElement(iq) method_name = 'on'+''.join(item.capitalize() for item in request.jingle.action.lower().split('-')) handler = getattr(self, method_name, None) if callable(handler): handler(request) else: raise error.StanzaError('bad-request') diff --git a/sylk/applications/xmppgateway/xmpp/subscription.py b/sylk/applications/xmppgateway/xmpp/subscription.py index 55f19c0..050dad9 100644 --- a/sylk/applications/xmppgateway/xmpp/subscription.py +++ b/sylk/applications/xmppgateway/xmpp/subscription.py @@ -1,201 +1,200 @@ from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.descriptor import WriteOnceAttribute from application.python.types import Singleton from eventlib import coros, proc -from zope.interface import implements +from zope.interface import implementer from sylk.applications.xmppgateway.xmpp.stanzas import SubscriptionPresence, ProbePresence, AvailabilityPresence __all__ = 'XMPPSubscription', 'XMPPIncomingSubscription', 'XMPPSubscriptionManager' class XMPPSubscription(object): local_identity = WriteOnceAttribute() remote_identity = WriteOnceAttribute() def __init__(self, local_identity, remote_identity): self.local_identity = local_identity self.remote_identity = remote_identity self.state = None self.channel = coros.queue() self._proc = None from sylk.applications.xmppgateway.xmpp import XMPPManager self.xmpp_manager = XMPPManager() @property def state(self): return self.__dict__['state'] @state.setter def state(self, new_state): prev_state = self.__dict__.get('state', None) self.__dict__['state'] = new_state if prev_state != new_state: NotificationCenter().post_notification('XMPPSubscriptionChangedState', sender=self, data=NotificationData(prev_state=prev_state, state=new_state)) def start(self): NotificationCenter().post_notification('XMPPSubscriptionDidStart', sender=self) self._proc = proc.spawn(self._run) self.subscribe() def end(self): if self.state == 'terminated': return self._proc.kill() self._proc = None NotificationCenter().post_notification('XMPPSubscriptionDidEnd', sender=self, data=NotificationData(originator='local')) self.state = 'terminated' def subscribe(self): self.state = 'subscribe_sent' stanza = SubscriptionPresence(self.local_identity, self.remote_identity, 'subscribe') self.xmpp_manager.send_stanza(stanza) # If we are already subscribed we may not receive an answer, send a probe just in case self._send_probe() def unsubscribe(self): self.state = 'unsubscribe_sent' stanza = SubscriptionPresence(self.local_identity, self.remote_identity, 'unsubscribe') self.xmpp_manager.send_stanza(stanza) def _send_probe(self): self.state = 'subscribe_sent' stanza = ProbePresence(self.local_identity, self.remote_identity) self.xmpp_manager.send_stanza(stanza) def _run(self): notification_center = NotificationCenter() while True: item = self.channel.wait() if isinstance(item, AvailabilityPresence): if self.state == 'subscribe_sent': self.state = 'active' notification_center.post_notification('XMPPSubscriptionGotNotify', sender=self, data=NotificationData(presence=item)) elif isinstance(item, SubscriptionPresence): if self.state == 'subscribe_sent' and item.type == 'subscribed': self.state = 'active' elif item.type == 'unsubscribed': prev_state = self.state self.state = 'terminated' if prev_state in ('active', 'unsubscribe_sent'): notification_center.post_notification('XMPPSubscriptionDidEnd', sender=self) else: notification_center.post_notification('XMPPSubscriptionDidFail', sender=self) break self._proc = None class XMPPIncomingSubscription(object): local_identity = WriteOnceAttribute() remote_identity = WriteOnceAttribute() def __init__(self, local_identity, remote_identity): self.local_identity = local_identity self.remote_identity = remote_identity self.state = None self.channel = coros.queue() self._proc = None from sylk.applications.xmppgateway.xmpp import XMPPManager self.xmpp_manager = XMPPManager() @property def state(self): return self.__dict__['state'] @state.setter def state(self, new_state): prev_state = self.__dict__.get('state', None) self.__dict__['state'] = new_state if prev_state != new_state: NotificationCenter().post_notification('XMPPIncomingSubscriptionChangedState', sender=self, data=NotificationData(prev_state=prev_state, state=new_state)) def start(self): NotificationCenter().post_notification('XMPPIncomingSubscriptionDidStart', sender=self) self._proc = proc.spawn(self._run) def end(self): if self.state == 'terminated': return self.state = 'terminated' self._proc.kill() self._proc = None NotificationCenter().post_notification('XMPPIncomingSubscriptionDidEnd', sender=self, data=NotificationData(originator='local')) def accept(self): self.state = 'active' stanza = SubscriptionPresence(self.local_identity, self.remote_identity, 'subscribed') self.xmpp_manager.send_stanza(stanza) def reject(self): self.state = 'terminating' stanza = SubscriptionPresence(self.local_identity, self.remote_identity, 'unsubscribed') self.xmpp_manager.send_stanza(stanza) self.end() def send_presence(self, stanza): self.xmpp_manager.send_stanza(stanza) def _run(self): notification_center = NotificationCenter() while True: item = self.channel.wait() if isinstance(item, SubscriptionPresence): if item.type == 'subscribe': notification_center.post_notification('XMPPIncomingSubscriptionGotSubscribe', sender=self) elif item.type == 'unsubscribe': self.state = 'terminated' notification_center = NotificationCenter() notification_center.post_notification('XMPPIncomingSubscriptionGotUnsubscribe', sender=self) notification_center.post_notification('XMPPIncomingSubscriptionDidEnd', sender=self, data=NotificationData(originator='local')) break elif isinstance(item, ProbePresence): notification_center = NotificationCenter() notification_center.post_notification('XMPPIncomingSubscriptionGotProbe', sender=self) self._proc = None -class XMPPSubscriptionManager(object): - __metaclass__ = Singleton - implements(IObserver) +@implementer(IObserver) +class XMPPSubscriptionManager(object, metaclass=Singleton): def __init__(self): self.incoming_subscriptions = {} self.outgoing_subscriptions = {} def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, name='XMPPSubscriptionDidStart') notification_center.add_observer(self, name='XMPPSubscriptionDidEnd') notification_center.add_observer(self, name='XMPPIncomingSubscriptionDidStart') notification_center.add_observer(self, name='XMPPIncomingSubscriptionDidEnd') def stop(self): notification_center = NotificationCenter() notification_center.remove_observer(self, name='XMPPSubscriptionDidStart') notification_center.remove_observer(self, name='XMPPSubscriptionDidEnd') notification_center.remove_observer(self, name='XMPPIncomingSubscriptionDidStart') notification_center.remove_observer(self, name='XMPPIncomingSubscriptionDidEnd') def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_XMPPSubscriptionDidStart(self, notification): subscription = notification.sender self.outgoing_subscriptions[(subscription.local_identity.uri, subscription.remote_identity.uri)] = subscription def _NH_XMPPSubscriptionDidEnd(self, notification): subscription = notification.sender del self.outgoing_subscriptions[(subscription.local_identity.uri, subscription.remote_identity.uri)] def _NH_XMPPIncomingSubscriptionDidStart(self, notification): subscription = notification.sender self.incoming_subscriptions[(subscription.local_identity.uri, subscription.remote_identity.uri)] = subscription def _NH_XMPPIncomingSubscriptionDidEnd(self, notification): subscription = notification.sender del self.incoming_subscriptions[(subscription.local_identity.uri, subscription.remote_identity.uri)] diff --git a/sylk/bonjour.py b/sylk/bonjour.py index e63c688..27f4c04 100644 --- a/sylk/bonjour.py +++ b/sylk/bonjour.py @@ -1,257 +1,257 @@ import uuid from application import log from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from eventlib import api, coros, proc from eventlib.green import select from sipsimple.account.bonjour import _bonjour, BonjourPresenceState, BonjourRegistrationFile from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.threading import call_in_twisted_thread, run_in_twisted_thread from sipsimple.threading.green import Command, run_in_green_thread from threading import Lock from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount class RestartSelect(Exception): pass +@implementer(IObserver) class BonjourService(object): - implements(IObserver) def __init__(self, service='sipfocus', name='SylkServer', uri_user=None, is_focus=True): self.account = DefaultAccount() self.service = service self.name = name self.uri_user = uri_user self.is_focus = is_focus self.id = str(uuid.uuid4()) self._stopped = True self._files = [] self._command_channel = coros.queue() self._select_proc = None self._register_timer = None self._update_timer = None self._lock = Lock() self.__dict__['presence_state'] = None @run_in_green_thread def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, name='NetworkConditionsDidChange') self._select_proc = proc.spawn(self._process_files) proc.spawn(self._handle_commands) self._activate() @run_in_green_thread def stop(self): self._deactivate() notification_center = NotificationCenter() notification_center.remove_observer(self, name='NetworkConditionsDidChange') self._select_proc.kill() self._command_channel.send_exception(api.GreenletExit) def _activate(self): self._stopped = False self._command_channel.send(Command('register')) def _deactivate(self): command = Command('stop') self._command_channel.send(command) command.wait() self._stopped = True def restart_registration(self): self._command_channel.send(Command('unregister')) self._command_channel.send(Command('register')) def update_registrations(self): self._command_channel.send(Command('update_registrations')) @property def presence_state(self): return self.__dict__['presence_state'] @presence_state.setter def presence_state(self, state): if state is not None and not isinstance(state, BonjourPresenceState): raise ValueError("state must be a %s instance or None" % BonjourPresenceState.__name__) with self._lock: old_state = self.__dict__['presence_state'] self.__dict__['presence_state'] = state if state != old_state: call_in_twisted_thread(self.update_registrations) def _register_cb(self, file, flags, error_code, name, regtype, domain): notification_center = NotificationCenter() file = BonjourRegistrationFile.find_by_file(file) if error_code == _bonjour.kDNSServiceErr_NoError: notification_center.post_notification('BonjourServiceRegistrationDidSucceed', sender=self, data=NotificationData(name=name, transport=file.transport)) else: error = _bonjour.BonjourError(error_code) notification_center.post_notification('BonjourServiceRegistrationDidFail', sender=self, data=NotificationData(reason=str(error), transport=file.transport)) self._files.remove(file) self._select_proc.kill(RestartSelect) file.close() if self._register_timer is None: self._register_timer = reactor.callLater(1, self._command_channel.send, Command('register')) def _process_files(self): while True: try: ready = select.select([f for f in self._files if not f.active and not f.closed], [], [])[0] except RestartSelect: continue else: for file in ready: file.active = True self._command_channel.send(Command('process_results', files=[f for f in ready if not f.closed])) def _handle_commands(self): while True: command = self._command_channel.wait() if not self._stopped: handler = getattr(self, '_CH_%s' % command.name) handler(command) def _CH_unregister(self, command): if self._register_timer is not None and self._register_timer.active(): self._register_timer.cancel() self._register_timer = None if self._update_timer is not None and self._update_timer.active(): self._update_timer.cancel() self._update_timer = None old_files = [] for file in (f for f in self._files[:] if isinstance(f, BonjourRegistrationFile)): old_files.append(file) self._files.remove(file) self._select_proc.kill(RestartSelect) for file in old_files: file.close() notification_center = NotificationCenter() for transport in set(file.transport for file in self._files): notification_center.post_notification('BonjourServiceRegistrationDidEnd', sender=self, data=NotificationData(transport=transport)) command.signal() def _CH_register(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() if self._register_timer is not None and self._register_timer.active(): self._register_timer.cancel() self._register_timer = None - supported_transports = set(transport for transport in settings.sip.transport_list if transport!='tls' or self.account.tls.certificate is not None) + supported_transports = set(transport for transport in settings.sip.transport_list if transport!='tls' or settings.tls.certificate is not None) registered_transports = set(file.transport for file in self._files if isinstance(file, BonjourRegistrationFile)) missing_transports = supported_transports - registered_transports added_transports = set() for transport in missing_transports: notification_center.post_notification('BonjourServiceWillRegister', sender=self, data=NotificationData(transport=transport)) try: contact_uri = self.account.contact[transport] contact_uri.user = self.uri_user if self.is_focus: contact_uri.parameters['isfocus'] = None txtdata = dict(txtvers=1, name=self.name, contact="<%s>" % str(contact_uri), instance_id=self.id) state = self.presence_state if state is not None: txtdata['state'] = state.state txtdata['note'] = state.note.encode('utf-8') file = _bonjour.DNSServiceRegister(name=str(contact_uri), regtype="_%s._%s" % (self.service, transport if transport == 'udp' else 'tcp'), port=contact_uri.port, callBack=self._register_cb, txtRecord=_bonjour.TXTRecord(items=txtdata)) except (_bonjour.BonjourError, KeyError) as e: notification_center.post_notification('BonjourServiceRegistrationDidFail', sender=self, data=NotificationData(reason=str(e), transport=transport)) else: self._files.append(BonjourRegistrationFile(file, transport)) added_transports.add(transport) if added_transports: self._select_proc.kill(RestartSelect) if added_transports != missing_transports: self._register_timer = reactor.callLater(1, self._command_channel.send, Command('register', command.event)) else: command.signal() def _CH_update_registrations(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() if self._update_timer is not None and self._update_timer.active(): self._update_timer.cancel() self._update_timer = None available_transports = settings.sip.transport_list old_files = [] for file in (f for f in self._files[:] if isinstance(f, BonjourRegistrationFile) and f.transport not in available_transports): old_files.append(file) self._files.remove(file) self._select_proc.kill(RestartSelect) for file in old_files: file.close() update_failure = False for file in (f for f in self._files if isinstance(f, BonjourRegistrationFile)): try: contact_uri = self.account.contact[file.transport] contact_uri.user = self.uri_user if self.is_focus: contact_uri.parameters['isfocus'] = None txtdata = dict(txtvers=1, name=self.name, contact="<%s>" % str(contact_uri), instance_id=self.id) state = self.presence_state if state is not None: txtdata['state'] = state.state txtdata['note'] = state.note.encode('utf-8') _bonjour.DNSServiceUpdateRecord(file.file, None, flags=0, rdata=_bonjour.TXTRecord(items=txtdata), ttl=0) except (_bonjour.BonjourError, KeyError) as e: notification_center.post_notification('BonjourServiceRegistrationUpdateDidFail', sender=self, data=NotificationData(reason=str(e), transport=file.transport)) update_failure = True self._command_channel.send(Command('register')) if update_failure: self._update_timer = reactor.callLater(1, self._command_channel.send, Command('update_registrations', command.event)) else: command.signal() def _CH_process_results(self, command): for file in (f for f in command.files if not f.closed): try: _bonjour.DNSServiceProcessResult(file.file) except: # Should we close the file? The documentation doesn't say anything about this. -Luci log.exception() for file in command.files: file.active = False self._files = [f for f in self._files if not f.closed] self._select_proc.kill(RestartSelect) def _CH_stop(self, command): if self._register_timer is not None and self._register_timer.active(): self._register_timer.cancel() self._register_timer = None if self._update_timer is not None and self._update_timer.active(): self._update_timer.cancel() self._update_timer = None old_files = self._files self._files = [] self._select_proc.kill(RestartSelect) for file in old_files: file.close() notification_center = NotificationCenter() for transport in set(file.transport for file in self._files): notification_center.post_notification('BonjourServiceRegistrationDidEnd', sender=self, data=NotificationData(transport=transport)) command.signal() @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_NetworkConditionsDidChange(self, notification): if self._files: self.restart_registration() diff --git a/sylk/configuration/datatypes.py b/sylk/configuration/datatypes.py index 998649e..3bd2c32 100644 --- a/sylk/configuration/datatypes.py +++ b/sylk/configuration/datatypes.py @@ -1,208 +1,208 @@ import os import re import socket -import urllib -import urlparse +import urllib.request, urllib.parse, urllib.error +import urllib.parse from application import log from application.system import host from sipsimple.configuration.datatypes import AudioCodecList, Hostname, SIPTransport class AudioCodecs(list): def __new__(cls, value): if isinstance(value, (tuple, list)): return [str(x) for x in value if x in AudioCodecList.available_values] or None - elif isinstance(value, basestring): + elif isinstance(value, str): if value.lower() in ('none', ''): return None return [x for x in re.split(r'\s*,\s*', value) if x in AudioCodecList.available_values] or None else: raise TypeError("value must be a string, list or tuple") class IPAddress(str): """An IP address in quad dotted number notation""" def __new__(cls, value): try: socket.inet_aton(value) except socket.error: raise ValueError("invalid IP address: %r" % value) except TypeError: raise TypeError("value must be a string") return str.__new__(cls, value) @property def normalized(self): if self == '0.0.0.0': return host.default_ip or '127.0.0.1' return str(self) class Port(int): def __new__(cls, value): try: value = int(value) except ValueError: return None if not (0 <= value <= 65535): raise ValueError("illegal port value: %s" % value) return value class PortRange(object): """A port range in the form start:end with start and end being even numbers in the [1024, 65536] range""" def __init__(self, value): self.start, self.end = [int(p) for p in value.split(':', 1)] - allowed = xrange(1024, 65537, 2) + allowed = range(1024, 65537, 2) if not (self.start in allowed and self.end in allowed and self.start < self.end): raise ValueError("bad range: %r: ports must be even numbers in the range [1024, 65536] with start < end" % value) class SIPProxyAddress(object): _description_re = re.compile(r"^(?P(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|([a-zA-Z0-9\-_]+(\.[a-zA-Z0-9\-_]+)*))(:(?P\d+))?(;transport=(?P.+))?$") def __new__(cls, description): if not description: return None if not cls._description_re.match(description): raise ValueError("illegal SIP proxy address: %s" % description) return super(SIPProxyAddress, cls).__new__(cls) def __init__(self, description): match = self.__class__._description_re.match(description) data = match.groupdict() host = data.get('host') port = data.get('port', None) or 5060 transport = data.get('transport', None) or 'udp' self.host = Hostname(host) self.port = Port(port) if self.port == 0: raise ValueError("illegal port value: 0") self.transport = SIPTransport(transport) def __getstate__(self): - return unicode(self) + return str(self) def __setstate__(self, state): if not self.__class__._description_re.match(state): raise ValueError("illegal SIP proxy address: %s" % state) self.__init__(state) def __eq__(self, other): try: return (self.host, self.port, self.transport) == (other.host, other.port, other.transport) except AttributeError: return False def __ne__(self, other): return not self.__eq__(other) def __hash__(self): return hash((self.host, self.port, self.transport)) def __unicode__(self): - return u'%s:%d;transport=%s' % (self.host, self.port, self.transport) + return '%s:%d;transport=%s' % (self.host, self.port, self.transport) -class Path(unicode): +class Path(str): def __new__(cls, path): if path: path = os.path.normpath(path) - return unicode.__new__(cls, path) + return str.__new__(cls, path) @property def normalized(self): return os.path.expanduser(self) class URL(object): """A class describing an URL and providing access to its elements""" def __init__(self, url): - scheme, netloc, path, query, fragment = urlparse.urlsplit(url) + scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url) if netloc: if "@" in netloc: userinfo, hostport = netloc.split("@", 1) if ":" in userinfo: username, password = userinfo.split(":", 1) else: username, password = userinfo, None else: username = password = None hostport = netloc if ':' in hostport: host, port = hostport.split(':', 1) else: host, port = hostport, None else: username = password = host = port = None self.original_url = url self.scheme = scheme self.username = username self.password = password self.host = host self.port = int(port) if port is not None else None self.path = path - self.query_items = dict(urlparse.parse_qsl(query)) + self.query_items = dict(urllib.parse.parse_qsl(query)) self.fragment = fragment def __str__(self): - return urlparse.urlunsplit((self.scheme, self.netloc, self.path, self.query, self.fragment)) + return urllib.parse.urlunsplit((self.scheme, self.netloc, self.path, self.query, self.fragment)) def __repr__(self): return '%s(%r)' % (self.__class__.__name__, self.__str__()) url = property(__str__) @property def query(self): - return urllib.urlencode(self.query_items) + return urllib.parse.urlencode(self.query_items) @property def netloc(self): authinfo = ':'.join(str(x) for x in (self.username, self.password) if x is not None) or None hostport = ':'.join(str(x) for x in (self.host or '', self.port) if x is not None) return '@'.join(x for x in (authinfo, hostport) if x is not None) class SRTPEncryption(str): available_values = ('opportunistic', 'sdes', 'zrtp', 'disabled') def __new__(cls, value): value = str(value) if value not in cls.available_values: raise ValueError("illegal value for SRTP encryption: %s" % value) return value class LogLevel(log.NamedLevel): __levelmap__ = {value.name: value for value in (log.level.DEBUG, log.level.INFO, log.level.WARNING, log.level.ERROR, log.level.CRITICAL)} def __new__(cls, value): value = str(value).upper() if value not in cls.__levelmap__: raise ValueError("illegal value for log level: %s" % value) log.level.current = cls.__levelmap__[value] return log.level.current class VideoBitrate(int): def __new__(cls, value): min_bitrate = 64000 max_bitrate = 4*1024*1024 # 4 Mb/s value = int(value) if not (min_bitrate <= value <= max_bitrate): raise ValueError('value must be an integer number between {} and {}'.format(min_bitrate, max_bitrate)) return value class VideoCodec(str): valid_values = 'h264', 'vp8', 'vp9' def __new__(cls, value): value = value.lower() if value not in cls.valid_values: raise ValueError('value must be one of: {!s}'.format(', '.join(cls.valid_values))) return str.__new__(cls, value) diff --git a/sylk/configuration/settings.py b/sylk/configuration/settings.py index 089b2e2..aee306a 100644 --- a/sylk/configuration/settings.py +++ b/sylk/configuration/settings.py @@ -1,170 +1,166 @@ """SIP SIMPLE SDK settings extensions""" import os from application import log from sipsimple.account import MSRPSettings as AccountMSRPSettings, NATTraversalSettings as AccountNATTraversalSettings -from sipsimple.account import RTPSettings as AccountRTPSettings, SIPSettings as AccountSIPSettings, TLSSettings as AccountTLSSettings, SRTPEncryptionSettings as AccountSRTPEncryptionSettings +from sipsimple.account import RTPSettings as AccountRTPSettings, SIPSettings as AccountSIPSettings, SRTPEncryptionSettings as AccountSRTPEncryptionSettings from sipsimple.account import MessageSummarySettings as AccountMessageSummarySettings, PresenceSettings as AccountPresenceSettingss, XCAPSettings as AccountXCAPSettings from sipsimple.configuration import CorrelatedSetting, Setting, SettingsObjectExtension from sipsimple.configuration.datatypes import MSRPConnectionModel, MSRPTransport, NonNegativeInteger, PortRange, SampleRate, SIPTransportList, SRTPKeyNegotiation from sipsimple.configuration.settings import AudioSettings, EchoCancellerSettings, FileTransferSettings, LogsSettings, RTPSettings, SIPSettings, TLSSettings from sylk import __version__ as server_version from sylk.configuration import ServerConfig, SIPConfig, MSRPConfig, RTPConfig from sylk.configuration.datatypes import AudioCodecs, Path, Port, SIPProxyAddress __all__ = 'AccountExtension', 'BonjourAccountExtension', 'SylkServerSettingsExtension' # Account settings extensions class AccountMessageSummarySettingsExtension(AccountMessageSummarySettings): enabled = Setting(type=bool, default=False) class AccountMSRPSettingsExtension(AccountMSRPSettings): transport = Setting(type=MSRPTransport, default='tls' if MSRPConfig.use_tls else 'tcp') connection_model = Setting(type=MSRPConnectionModel, default='relay' if ServerConfig.enable_bonjour else 'acm') class AccountNATTraversalSettingsExtension(AccountNATTraversalSettings): use_ice = Setting(type=bool, default=SIPConfig.enable_ice) use_msrp_relay_for_outbound = Setting(type=bool, default=False) class AccountPresenceSettingssExtension(AccountPresenceSettingss): enabled = Setting(type=bool, default=False) if RTPConfig.srtp_encryption == 'disabled': # doesn't matter because it's disabled srtp_key_negotiation = 'opportunistic' elif RTPConfig.srtp_encryption == 'sdes': srtp_key_negotiation = 'sdes_optional' else: srtp_key_negotiation = RTPConfig.srtp_encryption class AccountSRTPEncryptionSettingsExtension(AccountSRTPEncryptionSettings): enabled = Setting(type=bool, default=RTPConfig.srtp_encryption!='disabled') key_negotiation = Setting(type=SRTPKeyNegotiation, default=srtp_key_negotiation) class AccountRTPSettingsExtension(AccountRTPSettings): audio_codec_list = Setting(type=AudioCodecs, default=None, nillable=True) encryption = AccountSRTPEncryptionSettingsExtension class AccountSIPSettingsExtension(AccountSIPSettings): register = Setting(type=bool, default=False) outbound_proxy = Setting(type=SIPProxyAddress, default=SIPConfig.outbound_proxy, nillable=True) -account_cert = ServerConfig.certificate -if account_cert is not None and not os.path.isfile(account_cert): - account_cert = None - - -class AccountTLSSettingsExtension(AccountTLSSettings): - certificate = Setting(type=Path, default=account_cert, nillable=True) - verify_server = Setting(type=bool, default=ServerConfig.verify_server) - class AccountXCAPSettingsExtension(AccountXCAPSettings): enabled = Setting(type=bool, default=False) class AccountExtension(SettingsObjectExtension): enabled = Setting(type=bool, default=True) message_summary = AccountMessageSummarySettingsExtension msrp = AccountMSRPSettingsExtension nat_traversal = AccountNATTraversalSettingsExtension presence = AccountPresenceSettingssExtension rtp = AccountRTPSettingsExtension sip = AccountSIPSettingsExtension - tls = AccountTLSSettingsExtension xcap = AccountXCAPSettingsExtension class BonjourAccountExtension(SettingsObjectExtension): enabled = Setting(type=bool, default=False) # General settings extensions class EchoCancellerSettingsExtension(EchoCancellerSettings): enabled = Setting(type=bool, default=False) tail_length = Setting(type=NonNegativeInteger, default=0) class AudioSettingsExtension(AudioSettings): input_device = Setting(type=str, default=None, nillable=True) output_device = Setting(type=str, default=None, nillable=True) sample_rate = Setting(type=SampleRate, default=RTPConfig.sample_rate) echo_canceller = EchoCancellerSettings class FileTransferSettingsExtension(FileTransferSettings): directory = Setting(type=Path, default=Path(os.path.join(ServerConfig.spool_dir.normalized, 'file_transfers'))) class LogsSettingsExtension(LogsSettings): directory = Setting(type=Path, default=ServerConfig.trace_dir) trace_sip = Setting(type=bool, default=ServerConfig.trace_sip) trace_msrp = Setting(type=bool, default=ServerConfig.trace_msrp) trace_pjsip = Setting(type=bool, default=ServerConfig.trace_core) class RTPSettingsExtension(RTPSettings): audio_codec_list = Setting(type=AudioCodecs, default=RTPConfig.audio_codecs) port_range = Setting(type=PortRange, default=PortRange(RTPConfig.port_range.start, RTPConfig.port_range.end)) timeout = Setting(type=NonNegativeInteger, default=RTPConfig.timeout) ca_file = ServerConfig.ca_file if ca_file is not None and not os.path.isfile(ca_file): ca_file = None +certificate = ServerConfig.certificate +if certificate is not None and not os.path.isfile(certificate): + certificate = None + class TLSSettingsExtension(TLSSettings): ca_list = Setting(type=Path, default=ca_file, nillable=True) + certificate = Setting(type=Path, default=certificate, nillable=True) + verify_server = Setting(type=bool, default=ServerConfig.verify_server) def sip_port_validator(port, sibling_port): if port == sibling_port != 0: raise ValueError("the TCP and TLS ports must be different") transport_list = [] if SIPConfig.local_udp_port is not None: transport_list.append('udp') if SIPConfig.local_tcp_port is not None: transport_list.append('tcp') tls_port = SIPConfig.local_tls_port -if tls_port is not None and None in (ca_file, account_cert): +if tls_port is not None and None in (ca_file, certificate): log.warning('Cannot enable TLS because the CA or the certificate are not specified') tls_port = None if tls_port is not None: transport_list.append('tls') class SIPSettingsExtension(SIPSettings): udp_port = Setting(type=Port, default=SIPConfig.local_udp_port, nillable=True) tcp_port = CorrelatedSetting(type=Port, sibling='tls_port', validator=sip_port_validator, default=SIPConfig.local_tcp_port, nillable=True) tls_port = CorrelatedSetting(type=Port, sibling='tcp_port', validator=sip_port_validator, default=tls_port, nillable=True) transport_list = Setting(type=SIPTransportList, default=transport_list) class SylkServerSettingsExtension(SettingsObjectExtension): user_agent = Setting(type=str, default='SylkServer-%s' % server_version) audio = AudioSettingsExtension file_transfer = FileTransferSettingsExtension logs = LogsSettingsExtension rtp = RTPSettingsExtension sip = SIPSettingsExtension tls = TLSSettingsExtension diff --git a/sylk/interfaces/sipthor.py b/sylk/interfaces/sipthor.py index bdfbc7d..272b3f4 100644 --- a/sylk/interfaces/sipthor.py +++ b/sylk/interfaces/sipthor.py @@ -1,110 +1,108 @@ from application import log from application.notification import NotificationCenter, NotificationData from application.python.types import Singleton from eventlib.twistedutil import block_on, callInGreenThread from gnutls.interfaces.twisted import TLSContext, X509Credentials from twisted.internet import defer from thor.eventservice import EventServiceClient, ThorEvent from thor.entities import ThorEntitiesRoleMap, GenericThorEntity as ThorEntity from thor.scheduler import KeepRunning import sylk from sylk.configuration import SIPConfig, ThorNodeConfig __all__ = 'ConferenceNode', -class ConferenceNode(EventServiceClient): - __metaclass__ = Singleton - +class ConferenceNode(EventServiceClient, metaclass=Singleton): topics = ["Thor.Members"] def __init__(self): pass def connectionLost(self, connector, reason): """Called when an event server connection goes away""" self.connections.discard(connector.transport) def connectionFailed(self, connector, reason): """Called when an event server connection has an unrecoverable error""" connector.failed = True available_connectors = set(c for c in self.connectors if not c.failed) if not available_connectors: NotificationCenter().post_notification('ThorNetworkGotFatalError', sender=self) def start(self, roles): # Needs to be called from a green thread - log.info('Publishing %s roles to SIPThor' % roles) + log.info('Publishing SIPThor roles: %s' % ", ".join(roles)) self.node = ThorEntity(SIPConfig.local_ip.normalized, roles, version=sylk.__version__) self.networks = {} self.presence_message = ThorEvent('Thor.Presence', self.node.id) self.shutdown_message = ThorEvent('Thor.Leave', self.node.id) credentials = X509Credentials(ThorNodeConfig.certificate, ThorNodeConfig.private_key, [ThorNodeConfig.ca]) credentials.verify_peer = True tls_context = TLSContext(credentials) EventServiceClient.__init__(self, ThorNodeConfig.domain, tls_context) def stop(self): # Needs to be called from a green thread self._shutdown() def _monitor_event_servers(self): def wrapped_func(): servers = self._get_event_servers() self._update_event_servers(servers) callInGreenThread(wrapped_func) return KeepRunning def _disconnect_all(self): for conn in self.connectors: conn.disconnect() def _shutdown(self): if self.disconnecting: return self.disconnecting = True self.dns_monitor.cancel() if self.advertiser: self.advertiser.cancel() if self.shutdown_message: self._publish(self.shutdown_message) requests = [conn.protocol.unsubscribe(*self.topics) for conn in self.connections] d = defer.DeferredList([request.deferred for request in requests]) block_on(d) self._disconnect_all() def handle_event(self, event): #print "Received event: %s" % event networks = self.networks role_map = ThorEntitiesRoleMap(event.message) # mapping between role names and lists of nodes with that role updated = False for role in self.node.roles + ('sip_proxy',): try: network = networks[role] except KeyError: from thor import network as thor_network network = thor_network.new(ThorNodeConfig.multiply) networks[role] = network new_nodes = set(node.ip for node in role_map.get(role, [])) - old_nodes = set(network.nodes) + old_nodes = set(node.decode() for node in network.nodes) added_nodes = new_nodes - old_nodes removed_nodes = old_nodes - new_nodes if removed_nodes: for node in removed_nodes: - network.remove_node(node) + network.remove_node(node.encode()) plural = len(removed_nodes) != 1 and 's' or '' log.info("removed %s node%s: %s" % (role, plural, ', '.join(removed_nodes))) updated = True if added_nodes: for node in added_nodes: - network.add_node(node) + network.add_node(node.encode()) plural = len(added_nodes) != 1 and 's' or '' log.info("added %s node%s: %s" % (role, plural, ', '.join(added_nodes))) updated = True if updated: NotificationCenter().post_notification('ThorNetworkGotUpdate', sender=self, data=NotificationData(networks=self.networks)) diff --git a/sylk/log.py b/sylk/log.py index 397627c..6f857c9 100644 --- a/sylk/log.py +++ b/sylk/log.py @@ -1,322 +1,333 @@ import inspect import logging import os from abc import ABCMeta, abstractproperty from application import log from application.notification import IObserver, NotificationCenter from application.python import Null from application.python.types import MarkerType, Singleton from application.system import makedirs from collections import defaultdict from itertools import chain from sipsimple.threading import run_in_thread -from zope.interface import implements +from zope.interface import implementer from sylk.applications import find_applications from sylk.configuration import ServerConfig __all__ = 'TraceLogManager', 'TraceLogger' - -class TraceLogManager(object): - __metaclass__ = Singleton - - implements(IObserver) +# Override emit() from logging.StreamHandler +def shemit(self, record): + try: + msg = self.format(record) + stream = self.stream + if type(msg) == bytes: + msg = msg.decode() + stream.write(msg + self.terminator) + self.flush() + except RecursionError: + raise + except Exception: + self.handleError(record) +setattr(logging.StreamHandler, 'emit', shemit) + + +@implementer(IObserver) +class TraceLogManager(object, metaclass=Singleton): def __init__(self): self.loggers = set() # todo: make it a list to preserve their order? self.notification_map = defaultdict(set) # maps notification names to trace loggers that are interested in them self.started = False def start(self): if self.started: return self.started = True directory = ServerConfig.trace_dir.normalized try: makedirs(directory) except Exception as e: log.error('Failed to create tracelog directory at {directory}: {exception!s}'.format(directory=directory, exception=e)) else: for logger in (logger for logger in self.loggers if logger.enabled): logger.start() for name in logger.handled_notifications: self.notification_map[name].add(logger) if self.notification_map: notification_center = NotificationCenter() notification_center.add_observer(self) log.info('TraceLogManager started in {} for: {}'.format(directory, ', '.join(sorted(logger.name for logger in self.loggers if logger.enabled)))) else: log.info('TraceLogManager started in {}'.format(directory)) def stop(self): if not self.started: return notification_center = NotificationCenter() notification_center.discard_observer(self) self.notification_map.clear() for logger in self.loggers: logger.stop() self.started = False @classmethod def register_logger(cls, logger): # this is a class method for convenience if inspect.isclass(logger) and issubclass(logger, TraceLogger): logger = logger() assert isinstance(logger, TraceLogger), 'logger must be a TraceLogger instance or class' self = cls() if logger in self.loggers: return self.loggers.add(logger) if self.started and logger.enabled: logger.start() for name in logger.handled_notifications: self.notification_map[name].add(logger) if self.notification_map: notification_center = NotificationCenter() notification_center.add_observer(self) log.info('TraceLogManager added {logger.name} logger for {logger.owner}'.format(logger=logger)) @run_in_thread('file-logging') def handle_notification(self, notification): for logger in self.notification_map.get(AllNotifications, ()): logger.handle_notification(notification) for logger in self.notification_map.get(notification.name, ()): handler = getattr(logger, '_NH_{notification.name}'.format(notification=notification), Null) handler(notification) class TraceLoggerType(ABCMeta, Singleton): def __init__(cls, name, bases, dic): super(TraceLoggerType, cls).__init__(name, bases, dic) if not inspect.isabstract(cls): # noinspection PyTypeChecker TraceLogManager.register_logger(cls) -class TraceLogger(object): +class TraceLogger(object, metaclass=TraceLoggerType): """ Abstract class that defines the interface for TraceLogger objects. A trace logger is created by subclassing TraceLogger and defining the name, owner, enabled and formatter class attributes and any number of notification handlers, which are instance methods that have a name starting with _NH_ followed by the notification name and receive a notification as sole argument. def _NH_SomeNotificationName(self, notification): self.logger.log_notification(notification) def _NH_SomeOtherNotificationName(self, notification): if some_condition: self.logger.log_notification(notification) Any non-abstract TraceLogger is automatically registered with the TraceLogManager and becomes operational if enabled. """ - __metaclass__ = TraceLoggerType - name = abstractproperty() # The name of this trace logger (should be the log filename without the .log extension) owner = abstractproperty() # The name of the application that owns this trace logger enabled = abstractproperty() # Boolean indicating if this trace logger is enabled (usually mirrors a configuration setting) formatter = abstractproperty() # The formatter used by this trace logger def __init__(self): self.logger = None @property def handled_notifications(self): return {name[4:] for name in dir(self.__class__) if name.startswith('_NH_') and callable(getattr(self, name))} # noinspection PyTypeChecker def start(self): if self.enabled and self.logger is None: self.logger = NotificationLogger(self.name, ServerConfig.trace_dir.normalized, self.formatter) def stop(self): self.logger = None def handle_notification(self, notification): self.logger.log_notification(notification) -class AllNotifications: - __metaclass__ = MarkerType +class AllNotifications(metaclass=MarkerType): + pass class NotificationLogger(object): def __init__(self, name, directory, formatter): self.name = name self.filename = os.path.join(directory, name + '.log') self.logger = logging.getLogger(name) self.logger.setLevel(logging.INFO) self.logger.propagate = False if not self.logger.handlers: handler = logging.FileHandler(self.filename) handler.setFormatter(formatter) self.logger.addHandler(handler) def log_notification(self, notification): self.logger.info('', extra=dict(notification=notification)) # The formatters for the trace loggers # class NetworkAddress(object): def __init__(self, address): self.type = address.type self.host = address.host self.port = address.port def __str__(self): return '{0.host}:{0.port}'.format(self) class DNSTraceFormatter(logging.Formatter): _format_success = '{time} DNS lookup {data.query_type} {data.query_name} succeeded: TTL={data.answer.ttl} {answer!s}' _format_failure = '{time} DNS lookup {data.query_type} {data.query_name} failed: {data.error!s}' def format(self, record): notification = record.notification if notification.data.error is None: if notification.data.query_type == 'A': answer = ' | '.join(record.address for record in notification.data.answer) elif notification.data.query_type == 'SRV': answer = ' | '.join('{0.priority} {0.weight} {0.port} {0.target}'.format(dns_record) for dns_record in notification.data.answer) elif notification.data.query_type == 'NAPTR': answer = ' | '.join('{0.order} {0.preference} {0.flags!r} {0.service!r} {0.regexp!r} {0.replacement}'.format(dns_record) for dns_record in notification.data.answer) else: answer = '' return self._format_success.format(time=notification.datetime, data=notification.data, answer=answer) else: return self._format_failure.format(time=notification.datetime, data=notification.data) class SIPTraceFormatter(logging.Formatter): - _format = '{time} Packet {packet} {direction} {data.transport} {data.source_ip}:{data.source_port} -> {data.destination_ip}:{data.destination_port}\n{data.data}\n' + _format = '{time} Packet {packet} {direction} {data.transport} {data.source_ip}:{data.source_port} -> {data.destination_ip}:{data.destination_port}\n{sip_packet}\n' _packet = 0 def format(self, record): self._packet += 1 notification = record.notification direction = 'INCOMING' if notification.data.received else 'OUTGOING' - return self._format.format(time=notification.datetime, packet=self._packet, direction=direction, data=notification.data) + return self._format.format(time=notification.datetime, packet=self._packet, direction=direction, data=notification.data, sip_packet=notification.data.data.decode()) # noinspection PyPep8Naming,PyMethodMayBeStatic class MSRPTraceFormatter(logging.Formatter): _packet = 0 def format(self, record): handler = getattr(self, '_FH_{}'.format(record.notification.name), Null) return handler(record) def _FH_MSRPLibraryLog(self, record): return '{notification.datetime} {notification.data.level!s} {notification.data.message}'.format(notification=record.notification) def _FH_MSRPTransportTrace(self, record): self._packet += 1 notification = record.notification local_address = NetworkAddress(notification.data.local_address) remote_address = NetworkAddress(notification.data.remote_address) if notification.data.direction == 'incoming': _format = '{time} Packet {packet} INCOMING {local_address} <- {remote_address}\n{data}\n' else: _format = '{time} Packet {packet} OUTGOING {local_address} -> {remote_address}\n{data}\n' return _format.format(time=notification.datetime, packet=self._packet, local_address=local_address, remote_address=remote_address, data=notification.data.data) class CoreTraceFormatter(logging.Formatter): _format = '{data.message}' # todo: include data.level? def format(self, record): # return self._format.format(data=record.notification.data) return record.notification.data.message class NotificationTraceFormatter(logging.Formatter): _format = '{notification.datetime} Notification name={notification.name} sender={notification.sender} data={notification.data}' def format(self, record): return self._format.format(notification=record.notification) # The trace loggers # class DNSTraceLogger(TraceLogger): name = 'dns_trace' owner = 'core' enabled = ServerConfig.trace_dns formatter = DNSTraceFormatter() def _NH_DNSLookupTrace(self, notification): self.logger.log_notification(notification) class SIPTraceLogger(TraceLogger): name = 'sip_trace' owner = 'core' enabled = ServerConfig.trace_sip formatter = SIPTraceFormatter() def _NH_SIPEngineSIPTrace(self, notification): self.logger.log_notification(notification) class MSRPTraceLogger(TraceLogger): name = 'msrp_trace' owner = 'core' enabled = ServerConfig.trace_msrp formatter = MSRPTraceFormatter() def _NH_MSRPTransportTrace(self, notification): self.logger.log_notification(notification) def _NH_MSRPLibraryLog(self, notification): if notification.data.level >= logging.INFO: # use logging.WARNING here? self.logger.log_notification(notification) class CoreTraceLogger(TraceLogger): name = 'core_trace' owner = 'core' enabled = ServerConfig.trace_core formatter = CoreTraceFormatter() def _NH_SIPEngineLog(self, notification): self.logger.log_notification(notification) class NotificationTraceLogger(TraceLogger): name = 'notification_trace' owner = 'core' enabled = ServerConfig.trace_notifications formatter = NotificationTraceFormatter() @property def handled_notifications(self): return {AllNotifications} def handle_notification(self, notification): if notification.name not in ('SIPEngineLog', 'SIPEngineSIPTrace'): self.logger.log_notification(notification) # Customize the default logging formatter # core_name = 'sylk.core' root_logger = log.get_logger() root_logger.name = core_name log.Formatter.prefix_format = '{record.levelname:<8s} [{record.name}] ' log.Formatter.prefix_length = max(len(name) for name in chain(find_applications(), [core_name])) + 8 + 4 # max name length + max level name length + 2 square brackets + 2 spaces diff --git a/sylk/resources.py b/sylk/resources.py index 6a5d7c4..c28e5da 100644 --- a/sylk/resources.py +++ b/sylk/resources.py @@ -1,50 +1,50 @@ import os import sys from application.python.descriptor import classproperty class Resources(object): """Provide access to SylkServer's resources""" _cached_directory = None @classproperty def directory(cls): if cls._cached_directory is None: binary_directory = os.path.dirname(os.path.realpath(sys.argv[0])) if os.path.basename(binary_directory) == 'bin': application_directory = os.path.dirname(binary_directory) resources_component = 'share/sylkserver' else: application_directory = binary_directory resources_component = 'resources' - cls._cached_directory = os.path.join(application_directory, resources_component).decode(sys.getfilesystemencoding()) + cls._cached_directory = os.path.join(application_directory, resources_component) return cls._cached_directory @classmethod def get(cls, resource): - return os.path.join(cls.directory, resource or u'') + return os.path.join(cls.directory, resource or '') class VarResources(object): """Provide access to SylkServer's resources that should go in /var""" _cached_directory = None @classproperty def directory(cls): if cls._cached_directory is None: binary_directory = os.path.dirname(os.path.realpath(sys.argv[0])) if os.path.basename(binary_directory) == 'bin': path = '/var' else: path = 'var' - cls._cached_directory = os.path.abspath(path).decode(sys.getfilesystemencoding()) + cls._cached_directory = os.path.abspath(path) return cls._cached_directory @classmethod def get(cls, resource): - return os.path.join(cls.directory, resource or u'') + return os.path.join(cls.directory, resource or '') diff --git a/sylk/server.py b/sylk/server.py index 03d93fa..171d09f 100644 --- a/sylk/server.py +++ b/sylk/server.py @@ -1,264 +1,283 @@ import os import sys from threading import Event from uuid import uuid4 from application import log from application.notification import NotificationCenter from application.python import Null from application.system import makedirs from eventlib import proc from sipsimple.account import Account, BonjourAccount, AccountManager from sipsimple.application import SIPApplication from sipsimple.audio import AudioDevice, RootAudioBridge from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import AudioMixer from sipsimple.lookup import DNSManager from sipsimple.storage import MemoryStorage from sipsimple.threading import ThreadManager from sipsimple.threading.green import run_in_green_thread from sipsimple.video import VideoDevice from twisted.internet import reactor +from gnutls.crypto import X509Certificate, X509PrivateKey +from gnutls.errors import GNUTLSError # Load stream extensions needed for integration with SIP SIMPLE SDK import sylk.streams; del sylk.streams from sylk.accounts import DefaultAccount from sylk.applications import IncomingRequestHandler from sylk.configuration import ServerConfig, SIPConfig, ThorNodeConfig from sylk.configuration.settings import AccountExtension, BonjourAccountExtension, SylkServerSettingsExtension from sylk.log import TraceLogManager from sylk.session import SessionManager from sylk.web import WebServer class SylkServer(SIPApplication): def __init__(self): self.request_handler = Null self.thor_interface = Null self.web_server = Null self.options = Null self.stopping_event = Event() self.stopped_event = Event() self.failed = False def start(self, options): self.options = options if self.options.enable_bonjour: ServerConfig.enable_bonjour = True notification_center = NotificationCenter() notification_center.add_observer(self, sender=self) notification_center.add_observer(self, name='ThorNetworkGotFatalError') Account.register_extension(AccountExtension) BonjourAccount.register_extension(BonjourAccountExtension) SIPSimpleSettings.register_extension(SylkServerSettingsExtension) super(SylkServer, self).start(MemoryStorage()) def run(self, options): """Start the server and wait for it to finish before returning control to the caller""" self.start(options) while not self.stopping_event.wait(9999): pass self.stopped_event.wait(5) def _initialize_core(self): # SylkServer needs to listen for extra events and request types notification_center = NotificationCenter() settings = SIPSimpleSettings() + log.info('TLS CA list: %s' % settings.tls.ca_list) + log.info('TLS Certificate: %s' % settings.tls.certificate) + log.info('TLS verify server: %s' % settings.tls.verify_server) + contents = open(settings.tls.certificate, 'rb').read() + if (contents): + try: + certificate = X509Certificate(contents) # validate the certificate + except GNUTLSError as e: + log.error("Invalid TLS certificate %s: %s" % (settings.tls.certificate, str(e))) + else: + try: + X509PrivateKey(contents) # validate the private key + except GNUTLSError as e: + log.error("Invalid TLS private key %s: %s" % (settings.tls.certificate, str(e))) + else: + log.info("TLS identity: %s" % certificate.subject) + # initialize core options = dict(# general ip_address=SIPConfig.local_ip, user_agent=settings.user_agent, # SIP detect_sip_loops=False, udp_port=settings.sip.udp_port if 'udp' in settings.sip.transport_list else None, tcp_port=settings.sip.tcp_port if 'tcp' in settings.sip.transport_list else None, - tls_port=None, + tls_port=settings.sip.tls_port if 'tls' in settings.sip.transport_list else None, # TLS - tls_verify_server=False, - tls_ca_file=None, - tls_cert_file=None, - tls_privkey_file=None, + tls_verify_server=settings.tls.verify_server, + tls_ca_file=os.path.expanduser(settings.tls.ca_list) if settings.tls.ca_list else None, + tls_cert_file=os.path.expanduser(settings.tls.certificate) if settings.tls.certificate else None, + tls_privkey_file=os.path.expanduser(settings.tls.certificate) if settings.tls.certificate else None, # rtp rtp_port_range=(settings.rtp.port_range.start, settings.rtp.port_range.end), # audio codecs=list(settings.rtp.audio_codec_list), # video video_codecs=list(settings.rtp.video_codec_list), enable_colorbar_device=True, # logging log_level=settings.logs.pjsip_level if settings.logs.trace_pjsip else 0, trace_sip=settings.logs.trace_sip, # events and requests to handle events={'conference': ['application/conference-info+xml'], 'presence': ['application/pidf+xml'], 'refer': ['message/sipfrag;version=2.0']}, - incoming_events={'conference', 'presence'}, + incoming_events={b'conference', b'presence'}, incoming_requests={'MESSAGE'}) notification_center.add_observer(self, sender=self.engine) self.engine.start(**options) @run_in_green_thread def _initialize_subsystems(self): notification_center = NotificationCenter() with self._lock: stop_pending = self._stop_pending if stop_pending: self.state = 'stopping' if stop_pending: notification_center.post_notification('SIPApplicationWillEnd', sender=self) reactor.stop() return account_manager = AccountManager() dns_manager = DNSManager() session_manager = SessionManager() settings = SIPSimpleSettings() # Initialize default account default_account = DefaultAccount() account_manager.default_account = default_account # initialize TLS self._initialize_tls() # initialize PJSIP internal resolver self.engine.set_nameservers(['8.8.8.8']) # initialize audio objects voice_mixer = AudioMixer(None, None, settings.audio.sample_rate, 0, 9999) self.voice_audio_device = AudioDevice(voice_mixer) self.voice_audio_bridge = RootAudioBridge(voice_mixer) self.voice_audio_bridge.add(self.voice_audio_device) # initialize video objects - self.video_device = VideoDevice(u'Colorbar generator', settings.video.resolution, settings.video.framerate) + self.video_device = VideoDevice('Colorbar generator', settings.video.resolution, settings.video.framerate) # initialize instance id settings.instance_id = uuid4().urn settings.save() # initialize ZRTP cache makedirs(ServerConfig.spool_dir.normalized) self.engine.zrtp_cache = os.path.join(ServerConfig.spool_dir.normalized, 'zrtp.db') # initialize middleware components dns_manager.start() account_manager.start() session_manager.start() notification_center.add_observer(self, name='CFGSettingsObjectDidChange') self.state = 'started' notification_center.post_notification('SIPApplicationDidStart', sender=self) # start SylkServer components self.web_server = WebServer() self.web_server.start() self.request_handler = IncomingRequestHandler() self.request_handler.start() if ThorNodeConfig.enabled: from sylk.interfaces.sipthor import ConferenceNode self.thor_interface = ConferenceNode() thor_roles = [] if 'conference' in self.request_handler.application_registry: thor_roles.append('conference_server') if 'xmppgateway' in self.request_handler.application_registry: thor_roles.append('xmpp_gateway') if 'webrtcgateway' in self.request_handler.application_registry: thor_roles.append('webrtc_gateway') self.thor_interface.start(thor_roles) @run_in_green_thread def _shutdown_subsystems(self): dns_manager = DNSManager() account_manager = AccountManager() session_manager = SessionManager() # terminate all sessions p = proc.spawn(session_manager.stop) p.wait() # shutdown SylkServer components procs = [proc.spawn(self.web_server.stop), proc.spawn(self.request_handler.stop), proc.spawn(self.thor_interface.stop)] proc.waitall(procs) # shutdown other middleware components procs = [proc.spawn(dns_manager.stop), proc.spawn(account_manager.stop)] proc.waitall(procs) # shutdown engine self.engine.stop() self.engine.join(timeout=5) # stop threads thread_manager = ThreadManager() thread_manager.stop() # stop the reactor reactor.stop() def _NH_AudioDevicesDidChange(self, notification): pass def _NH_DefaultAudioDeviceDidChange(self, notification): pass def _NH_SIPApplicationFailedToStartTLS(self, notification): log.fatal('Could not set TLS options: %s' % notification.data.error) sys.exit(1) def _NH_SIPApplicationWillStart(self, notification): tracelog_manager = TraceLogManager() tracelog_manager.start() def _NH_SIPApplicationDidStart(self, notification): settings = SIPSimpleSettings() local_ip = SIPConfig.local_ip log.info('SylkServer started; listening on:') for transport in settings.sip.transport_list: try: log.info(' %s:%d (%s)' % (local_ip, getattr(self.engine, '%s_port' % transport), transport.upper())) except TypeError: pass available_audio_codecs_print = list(codec.decode() for codec in self.engine._ua.available_codecs) available_video_codecs_print = list(codec.decode() for codec in self.engine._ua.available_video_codecs) log.info("Available audio codecs: %s" % ", ".join(available_audio_codecs_print)) log.info("Enabled audio codecs: %s" % ", ".join(settings.rtp.audio_codec_list)) log.info("Available video codecs: %s" % ", ".join(available_video_codecs_print)) log.info("Enabled video codecs: %s" % ", ".join(settings.rtp.video_codec_list)) def _NH_SIPApplicationWillEnd(self, notification): log.info('Stopping SylkServer...') self.stopping_event.set() def _NH_SIPApplicationDidEnd(self, notification): log.info('SIP application ended') tracelog_manager = TraceLogManager() tracelog_manager.stop() if not self.stopping_event.is_set(): log.warning('SIP application ended without shutting down all subsystems') self.stopping_event.set() self.stopped_event.set() def _NH_SIPEngineDidFail(self, notification): log.error('SIP engine failed') self.failed = True super(SylkServer, self)._NH_SIPEngineDidFail(notification) def _NH_ThorNetworkGotFatalError(self, notification): log.error('All Thor Event Servers have unrecoverable errors') diff --git a/sylk/session.py b/sylk/session.py index 083f133..3ab7275 100644 --- a/sylk/session.py +++ b/sylk/session.py @@ -1,2002 +1,2007 @@ import random from application import log from application.notification import IObserver, Notification, NotificationCenter, NotificationData from application.python import Null, limit from application.python.decorator import decorator, preserve_signature from application.python.types import Singleton from application.system import host from eventlib import api, coros, proc from sipsimple.account import AccountManager from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import Engine, Invitation, Subscription, SIPCoreError, SIPCoreInvalidStateError, PJSIPError, sip_status_messages from sipsimple.core import ContactHeader, RouteHeader, FromHeader, ToHeader, ReasonHeader, WarningHeader from sipsimple.core import SIPURI, SDPConnection, SDPSession, SDPMediaStream from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.payloads import ParserError from sipsimple.payloads.conference import ConferenceDocument from sipsimple.streams import MediaStreamRegistry, InvalidStreamError, UnknownStreamError from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, run_in_green_thread from sipsimple.util import ISOTimestamp from threading import RLock from time import time from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sylk.accounts import DefaultAccount from sylk.configuration import SIPConfig class InvitationDisconnectedError(Exception): def __init__(self, invitation, data): self.invitation = invitation self.data = data class MediaStreamDidNotInitializeError(Exception): def __init__(self, stream, data): self.stream = stream self.data = data class MediaStreamDidFailError(Exception): def __init__(self, stream, data): self.stream = stream self.data = data class SubscriptionError(Exception): def __init__(self, error, timeout, **attributes): self.error = error self.timeout = timeout self.attributes = attributes class SIPSubscriptionDidFail(Exception): def __init__(self, data): self.data = data class InterruptSubscription(Exception): pass class TerminateSubscription(Exception): pass class IllegalStateError(RuntimeError): pass @decorator def transition_state(required_state, new_state): def state_transitioner(func): @preserve_signature(func) def wrapper(obj, *args, **kwargs): with obj._lock: if obj.state != required_state: raise IllegalStateError('cannot call %s in %s state' % (func.__name__, obj.state)) obj.state = new_state return func(obj, *args, **kwargs) return wrapper return state_transitioner @decorator def check_state(required_states): def state_checker(func): @preserve_signature(func) def wrapper(obj, *args, **kwargs): if obj.state not in required_states: raise IllegalStateError('cannot call %s in %s state' % (func.__name__, obj.state)) return func(obj, *args, **kwargs) return wrapper return state_checker +@implementer(IObserver) class ConferenceHandler(object): - implements(IObserver) def __init__(self, session): self.session = session self.active = False self.subscribed = False self._command_proc = None self._command_channel = coros.queue() self._data_channel = coros.queue() self._subscription = None self._subscription_proc = None self._subscription_timer = None notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) notification_center.add_observer(self, name='NetworkConditionsDidChange') self._command_proc = proc.spawn(self._run) def _run(self): while True: command = self._command_channel.wait() handler = getattr(self, '_CH_%s' % command.name) handler(command) def _activate(self): self.active = True command = Command('subscribe') self._command_channel.send(command) return command def _deactivate(self): self.active = False command = Command('unsubscribe') self._command_channel.send(command) return command def _resubscribe(self): command = Command('subscribe') self._command_channel.send(command) return command def _terminate(self): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=self.session) notification_center.remove_observer(self, name='NetworkConditionsDidChange') self._deactivate() command = Command('terminate') self._command_channel.send(command) command.wait() self.session = None def _CH_subscribe(self, command): if self._subscription_timer is not None and self._subscription_timer.active(): self._subscription_timer.cancel() self._subscription_timer = None if self._subscription_proc is not None: subscription_proc = self._subscription_proc subscription_proc.kill(InterruptSubscription) subscription_proc.wait() self._subscription_proc = proc.spawn(self._subscription_handler, command) def _CH_unsubscribe(self, command): # Cancel any timer which would restart the subscription process if self._subscription_timer is not None and self._subscription_timer.active(): self._subscription_timer.cancel() self._subscription_timer = None if self._subscription_proc is not None: subscription_proc = self._subscription_proc subscription_proc.kill(TerminateSubscription) subscription_proc.wait() self._subscription_proc = None command.signal() def _CH_terminate(self, command): command.signal() raise proc.ProcExit() def _subscription_handler(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() try: # Lookup routes account = self.session.account if account.sip.outbound_proxy is not None: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) elif account.sip.always_use_my_proxy: uri = SIPURI(host=account.id.domain) else: uri = SIPURI.new(self.session.remote_identity.uri) lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError as e: timeout = random.uniform(15, 30) raise SubscriptionError(error='DNS lookup failed: %s' % e, timeout=timeout) target_uri = SIPURI.new(self.session.remote_identity.uri) refresh_interval = getattr(command, 'refresh_interval', account.sip.subscribe_interval) timeout = time() + 30 for route in routes: remaining_time = timeout - time() if remaining_time > 0: try: contact_uri = account.contact[route] except KeyError: continue subscription = Subscription(target_uri, FromHeader(SIPURI.new(self.session.local_identity.uri)), ToHeader(target_uri), ContactHeader(contact_uri), 'conference', RouteHeader(route.uri), credentials=account.credentials, refresh=refresh_interval) notification_center.add_observer(self, sender=subscription) try: subscription.subscribe(timeout=limit(remaining_time, min=1, max=5)) except SIPCoreError: notification_center.remove_observer(self, sender=subscription) timeout = 5 raise SubscriptionError(error='Internal error', timeout=timeout) self._subscription = subscription try: while True: notification = self._data_channel.wait() if notification.sender is subscription and notification.name == 'SIPSubscriptionDidStart': break except SIPSubscriptionDidFail as e: notification_center.remove_observer(self, sender=subscription) self._subscription = None if e.data.code == 407: # Authentication failed, so retry the subscription in some time timeout = random.uniform(60, 120) raise SubscriptionError(error='Authentication failed', timeout=timeout) elif e.data.code == 423: # Get the value of the Min-Expires header timeout = random.uniform(60, 120) if e.data.min_expires is not None and e.data.min_expires > refresh_interval: raise SubscriptionError(error='Interval too short', timeout=timeout, min_expires=e.data.min_expires) else: raise SubscriptionError(error='Interval too short', timeout=timeout) elif e.data.code in (405, 406, 489, 1400): command.signal(e) return else: # Otherwise just try the next route continue else: self.subscribed = True command.signal() break else: # There are no more routes to try, reschedule the subscription timeout = random.uniform(60, 180) raise SubscriptionError(error='No more routes to try', timeout=timeout) # At this point it is subscribed. Handle notifications and ending/failures. try: while True: notification = self._data_channel.wait() if notification.sender is not self._subscription: continue if notification.name == 'SIPSubscriptionGotNotify': if notification.data.event == 'conference' and notification.data.body: try: conference_info = ConferenceDocument.parse(notification.data.body) except ParserError: pass else: notification_center.post_notification('SIPSessionGotConferenceInfo', sender=self.session, data=NotificationData(conference_info=conference_info)) elif notification.name == 'SIPSubscriptionDidEnd': break except SIPSubscriptionDidFail: self._command_channel.send(Command('subscribe')) notification_center.remove_observer(self, sender=self._subscription) except InterruptSubscription as e: if not self.subscribed: command.signal(e) if self._subscription is not None: notification_center.remove_observer(self, sender=self._subscription) try: self._subscription.end(timeout=2) except SIPCoreError: pass except TerminateSubscription as e: if not self.subscribed: command.signal(e) if self._subscription is not None: try: self._subscription.end(timeout=2) except SIPCoreError: pass else: try: while True: notification = self._data_channel.wait() if notification.sender is self._subscription and notification.name == 'SIPSubscriptionDidEnd': break except SIPSubscriptionDidFail: pass finally: notification_center.remove_observer(self, sender=self._subscription) except SubscriptionError as e: if 'min_expires' in e.attributes: command = Command('subscribe', command.event, refresh_interval=e.attributes['min_expires']) else: command = Command('subscribe', command.event) self._subscription_timer = reactor.callLater(e.timeout, self._command_channel.send, command) finally: self.subscribed = False self._subscription = None self._subscription_proc = None @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSubscriptionDidStart(self, notification): self._data_channel.send(notification) def _NH_SIPSubscriptionDidEnd(self, notification): self._data_channel.send(notification) def _NH_SIPSubscriptionDidFail(self, notification): self._data_channel.send_exception(SIPSubscriptionDidFail(notification.data)) def _NH_SIPSubscriptionGotNotify(self, notification): self._data_channel.send(notification) def _NH_SIPSessionDidStart(self, notification): if self.session.remote_focus: self._activate() @run_in_green_thread def _NH_SIPSessionDidFail(self, notification): self._terminate() @run_in_green_thread def _NH_SIPSessionDidEnd(self, notification): self._terminate() def _NH_SIPSessionDidRenegotiateStreams(self, notification): if self.session.remote_focus and not self.active: self._activate() elif not self.session.remote_focus and self.active: self._deactivate() def _NH_NetworkConditionsDidChange(self, notification): if self.active: self._resubscribe() +@implementer(IObserver) class Session(object): - implements(IObserver) media_stream_timeout = 15 short_reinvite_timeout = 5 def __init__(self, account): self.account = account self.direction = None self.end_time = None self.on_hold = False self.proposed_streams = None self.route = None self.state = None self.start_time = None self.streams = None self.transport = None self.local_focus = False self.remote_focus = False self.greenlet = None self.conference = None self._channel = coros.queue() self._hold_in_progress = False self._invitation = None self._local_identity = None self._remote_identity = None self._lock = RLock() def init_incoming(self, invitation, data): remote_sdp = invitation.sdp.proposed_remote if not remote_sdp: invitation.send_response(488) return self.proposed_streams = [] for index, media_stream in enumerate(remote_sdp.media): if media_stream.port != 0: for stream_type in MediaStreamRegistry: try: stream = stream_type.new_from_sdp(self, remote_sdp, index) except UnknownStreamError: continue except InvalidStreamError as e: log.error("Invalid stream: {}".format(e)) break except Exception as e: log.exception("Exception occurred while setting up stream from SDP: {}".format(e)) break else: stream.index = index self.proposed_streams.append(stream) break if not self.proposed_streams: invitation.send_response(488) return if 'Replaces' in data.headers: invitation.send_response(403) return self.direction = 'incoming' self.state = 'incoming' self.transport = invitation.transport self._invitation = invitation self.conference = ConferenceHandler(self) if 'isfocus' in invitation.remote_contact_header.parameters: self.remote_focus = True notification_center = NotificationCenter() notification_center.add_observer(self, sender=invitation) notification_center.post_notification('SIPSessionNewIncoming', self, NotificationData(streams=self.proposed_streams, headers=data.headers)) @transition_state(None, 'connecting') @run_in_green_thread def connect(self, from_header, to_header, route, streams, is_focus=False, contact_header=None, credentials=None, extra_headers=None): self.greenlet = api.getcurrent() notification_center = NotificationCenter() settings = SIPSimpleSettings() connected = False unhandled_notifications = [] extra_headers = extra_headers or [] if {'to', 'from', 'via', 'contact', 'route', 'record-route'}.intersection(header.name.lower() for header in extra_headers): raise RuntimeError('invalid header in extra_headers: To, From, Via, Contact, Route and Record-Route headers are not allowed') self.direction = 'outgoing' self.proposed_streams = streams self.route = route self.transport = self.route.transport self.local_focus = is_focus self._invitation = Invitation() self._local_identity = from_header self._remote_identity = to_header self.conference = ConferenceHandler(self) notification_center.add_observer(self, sender=self._invitation) notification_center.post_notification('SIPSessionNewOutgoing', self, NotificationData(streams=streams[:])) for stream in self.proposed_streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='outgoing') try: wait_count = len(self.proposed_streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 if contact_header is None: try: contact_uri = self.account.contact[self.route] except KeyError as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='local', code=480, reason=sip_status_messages[480], error=str(e)) return else: contact_header = ContactHeader(contact_uri) if SIPConfig.advertised_ip not in (None, '0.0.0.0'): local_ip = SIPConfig.advertised_ip.normalized elif SIPConfig.local_ip not in (None, '0.0.0.0'): local_ip = SIPConfig.local_ip.normalized else: local_ip = contact_header.uri.host - connection = SDPConnection(local_ip) - local_sdp = SDPSession(local_ip, name=settings.user_agent) + connection = SDPConnection(local_ip.encode()) + local_sdp = SDPSession(local_ip.encode(), name=settings.user_agent.encode()) for index, stream in enumerate(self.proposed_streams): stream.index = index media = stream.get_local_media(remote_sdp=None, index=index) if media.connection is None or (media.connection is not None and not media.has_ice_attributes and not media.has_ice_candidates): media.connection = connection local_sdp.media.append(media) route_header = RouteHeader(self.route.uri) if is_focus: - contact_header.parameters['isfocus'] = None + contact_header.parameters[b'isfocus'] = None self._invitation.send_invite(to_header.uri, from_header, to_header, route_header, contact_header, local_sdp, credentials, extra_headers) try: with api.timeout(settings.sip.invite_timeout): while True: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp break else: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='remote', code=0, reason=None, error='SDP negotiation failed: %s' % notification.data.error) return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'early': if notification.data.code == 180: notification_center.post_notification('SIPSessionGotRingIndication', self, ) notification_center.post_notification('SIPSessionGotProvisionalResponse', self, NotificationData(code=notification.data.code, reason=notification.data.reason)) elif notification.data.state == 'connected': if not connected: connected = True else: unhandled_notifications.append(notification) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except api.TimeoutError: self.end() return notification_center.post_notification('SIPSessionWillStart', self) stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, local_media in enumerate(local_sdp.media): remote_media = remote_sdp.media[index] stream = stream_map[index] if remote_media.port: stream.start(local_sdp, remote_sdp, index) else: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() removed_streams = [stream for stream in self.proposed_streams if stream.index >= len(local_sdp.media)] for stream in removed_streams: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() invitation_notifications = [] with api.timeout(self.media_stream_timeout): wait_count = len(self.proposed_streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 elif notification.name == 'SIPInvitationChangedState': invitation_notifications.append(notification) [self._channel.send(notification) for notification in invitation_notifications] while not connected or self._channel: notification = self._channel.wait() if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'early': if notification.data.code == 180: notification_center.post_notification('SIPSessionGotRingIndication', self) notification_center.post_notification('SIPSessionGotProvisionalResponse', self, NotificationData(code=notification.data.code, reason=notification.data.reason)) elif notification.data.state == 'connected': if not connected: connected = True else: unhandled_notifications.append(notification) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except (MediaStreamDidNotInitializeError, MediaStreamDidFailError, api.TimeoutError) as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() if isinstance(e, api.TimeoutError): error = 'media stream timed out while starting' elif isinstance(e, MediaStreamDidNotInitializeError): error = 'media stream did not initialize: %s' % e.data.reason else: error = 'media stream failed: %s' % e.data.reason self._fail(originator='local', code=0, reason=None, error=error) except InvitationDisconnectedError as e: notification_center.remove_observer(self, sender=self._invitation) for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.state = 'terminated' # As weird as it may sound, PJSIP accepts a BYE even without receiving a final response to the INVITE if e.data.prev_state in ('connecting', 'connected') or getattr(e.data, 'method', None) == 'BYE': notification_center.post_notification('SIPSessionWillEnd', self, NotificationData(originator=e.data.originator)) self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator=e.data.originator, end_reason=e.data.disconnect_reason)) else: if e.data.originator == 'remote': code = e.data.code reason = e.data.reason else: code = getattr(e.data, 'code', 0) reason = getattr(e.data, 'reason', 'Session disconnected') if e.data.originator == 'remote' and code // 100 == 3: redirect_identities = e.data.headers.get('Contact', []) else: redirect_identities = None notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator=e.data.originator, code=code, reason=reason, failure_reason=e.data.disconnect_reason, redirect_identities=redirect_identities)) self.greenlet = None except SIPCoreError as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='local', code=0, reason=None, error='SIP core error: %s' % str(e)) else: self.greenlet = None self.state = 'connected' self.streams = self.proposed_streams self.proposed_streams = None self.start_time = ISOTimestamp.now() any_stream_ice = any(getattr(stream, 'ice_active', False) for stream in self.streams) if any_stream_ice: self._reinvite_after_ice() notification_center.post_notification('SIPSessionDidStart', self, NotificationData(streams=self.streams[:])) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() def _reinvite_after_ice(self): # This function does not do any error checking, it's designed to be called at the end of connect and ad self.state = 'sending_proposal' self.greenlet = api.getcurrent() local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for index, stream in enumerate(self.streams): local_sdp.media[index] = stream.get_local_media(remote_sdp=None, index=index) self._invitation.send_reinvite(sdp=local_sdp) received_invitation_state = False received_sdp_update = False try: with api.timeout(self.short_reinvite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for index, stream in enumerate(self.streams): stream.update(local_sdp, remote_sdp, index) else: return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True elif notification.data.state == 'disconnected': self.end() return except Exception: pass finally: self.state = 'connected' self.greenlet = None @check_state(['incoming', 'received_proposal']) @run_in_green_thread def send_ring_indication(self): try: self._invitation.send_response(180) except SIPCoreInvalidStateError: pass # The INVITE session might have already been cancelled; ignore the error @transition_state('incoming', 'accepting') @run_in_green_thread def accept(self, streams, is_focus=False, extra_headers=None): self.greenlet = api.getcurrent() notification_center = NotificationCenter() settings = SIPSimpleSettings() self.local_focus = is_focus connected = False unhandled_notifications = [] extra_headers = extra_headers or [] if {'to', 'from', 'via', 'contact', 'route', 'record-route'}.intersection(header.name.lower() for header in extra_headers): raise RuntimeError('invalid header in extra_headers: To, From, Via, Contact, Route and Record-Route headers are not allowed') for stream in self.proposed_streams: if stream in streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='incoming') self.proposed_streams = streams wait_count = len(self.proposed_streams) try: while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 remote_sdp = self._invitation.sdp.proposed_remote if SIPConfig.advertised_ip not in (None, '0.0.0.0'): local_ip = SIPConfig.advertised_ip.normalized elif SIPConfig.local_ip not in (None, '0.0.0.0'): local_ip = SIPConfig.local_ip.normalized else: sdp_connection = remote_sdp.connection or next(media.connection for media in remote_sdp.media if media.connection is not None) - local_ip = host.outgoing_ip_for(sdp_connection.address) if sdp_connection.address != '0.0.0.0' else sdp_connection.address + sdp_ip = sdp_connection.address.decode() if isinstance(sdp_connection.address, bytes) else sdp_connection.address + local_ip = host.outgoing_ip_for(sdp_ip) if sdp_ip != '0.0.0.0' else sdp_ip + if local_ip is None: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='local', code=500, reason=sip_status_messages[500], error='could not get local IP address') return - connection = SDPConnection(local_ip) - local_sdp = SDPSession(local_ip, name=settings.user_agent) + + connection = SDPConnection(local_ip.encode()) + local_sdp = SDPSession(local_ip.encode(), name=settings.user_agent.encode()) stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, media in enumerate(remote_sdp.media): stream = stream_map.get(index, None) if stream is not None: media = stream.get_local_media(remote_sdp=remote_sdp, index=index) if not media.has_ice_attributes and not media.has_ice_candidates: media.connection = connection else: media = SDPMediaStream.new(media) media.connection = connection media.port = 0 media.attributes = [] media.bandwidth_info = [] local_sdp.media.append(media) contact_header = ContactHeader.new(self._invitation.local_contact_header) + try: local_contact_uri = self.account.contact[self._invitation.transport] contact_header.uri = local_contact_uri except KeyError: pass + if is_focus: - contact_header.parameters['isfocus'] = None + contact_header.parameters[b'isfocus'] = None + self._invitation.send_response(200, contact_header=contact_header, sdp=local_sdp, extra_headers=extra_headers) notification_center.post_notification('SIPSessionWillStart', sender=self) # Local and remote SDPs will be set after the 200 OK is sent while True: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp break else: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='remote', code=0, reason=None, error='SDP negotiation failed: %s' % notification.data.error) return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected': if not connected: connected = True elif notification.data.prev_state == 'connected': unhandled_notifications.append(notification) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) wait_count = 0 stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, local_media in enumerate(local_sdp.media): remote_media = remote_sdp.media[index] stream = stream_map.get(index, None) if stream is not None: if remote_media.port: wait_count += 1 stream.start(local_sdp, remote_sdp, index) else: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() removed_streams = [stream for stream in self.proposed_streams if stream.index >= len(local_sdp.media)] for stream in removed_streams: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() with api.timeout(self.media_stream_timeout): while wait_count > 0 or not connected or self._channel: notification = self._channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected': if not connected: connected = True elif notification.data.prev_state == 'connected': unhandled_notifications.append(notification) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) else: unhandled_notifications.append(notification) except (MediaStreamDidNotInitializeError, MediaStreamDidFailError, api.TimeoutError) as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() reason_header = None if isinstance(e, api.TimeoutError): if wait_count > 0: error = 'media stream timed out while starting' else: error = 'No ACK received' reason_header = ReasonHeader('SIP') reason_header.cause = 500 reason_header.text = 'Missing ACK' elif isinstance(e, MediaStreamDidNotInitializeError): error = 'media stream did not initialize: %s' % e.data.reason reason_header = ReasonHeader('SIP') reason_header.cause = 500 reason_header.text = 'media stream did not initialize' else: error = 'media stream failed: %s' % e.data.reason reason_header = ReasonHeader('SIP') reason_header.cause = 500 reason_header.text = 'media stream failed to start' self.start_time = ISOTimestamp.now() if self._invitation.state in ('incoming', 'early'): self._fail(originator='local', code=500, reason=sip_status_messages[500], error=error, reason_header=reason_header) else: self._fail(originator='local', code=0, reason=None, error=error, reason_header=reason_header) except InvitationDisconnectedError as e: notification_center.remove_observer(self, sender=self._invitation) for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.state = 'terminated' if e.data.prev_state in ('incoming', 'early'): notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='remote', code=487, reason='Session Cancelled', failure_reason=e.data.disconnect_reason, redirect_identities=None)) elif e.data.prev_state == 'connecting' and e.data.disconnect_reason == 'missing ACK': notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=200, reason=sip_status_messages[200], failure_reason=e.data.disconnect_reason, redirect_identities=None)) else: notification_center.post_notification('SIPSessionWillEnd', self, NotificationData(originator='remote')) self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator='remote', end_reason=e.data.disconnect_reason)) self.greenlet = None except SIPCoreInvalidStateError: # the only reason for which this error can be thrown is if invitation.send_response was called after the INVITE session was cancelled by the remote party notification_center.remove_observer(self, sender=self._invitation) for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.greenlet = None self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='remote', code=487, reason='Session Cancelled', failure_reason='user request', redirect_identities=None)) except SIPCoreError as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='local', code=500, reason=sip_status_messages[500], error='SIP core error: %s' % str(e)) else: self.greenlet = None self.state = 'connected' self.streams = self.proposed_streams self.proposed_streams = None self.start_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidStart', self, NotificationData(streams=self.streams[:])) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None @transition_state('incoming', 'terminating') @run_in_green_thread def reject(self, code=603, reason=None): self.greenlet = api.getcurrent() notification_center = NotificationCenter() try: self._invitation.send_response(code, reason) with api.timeout(1): while True: notification = self._channel.wait() if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'disconnected': break except SIPCoreInvalidStateError: # the only reason for which this error can be thrown is if invitation.send_response was called after the INVITE session was cancelled by the remote party self.greenlet = None except SIPCoreError as e: self._fail(originator='local', code=500, reason=sip_status_messages[500], error='SIP core error: %s' % str(e)) except api.TimeoutError: notification_center.remove_observer(self, sender=self._invitation) self.greenlet = None self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=code, reason=sip_status_messages[code], failure_reason='timeout', redirect_identities=None)) else: notification_center.remove_observer(self, sender=self._invitation) self.greenlet = None self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=code, reason=sip_status_messages[code], failure_reason='user request', redirect_identities=None)) finally: self.greenlet = None @transition_state('received_proposal', 'accepting_proposal') @run_in_green_thread def accept_proposal(self, streams): self.greenlet = api.getcurrent() notification_center = NotificationCenter() unhandled_notifications = [] streams = [stream for stream in streams if stream in self.proposed_streams] for stream in streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='incoming') try: wait_count = len(streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 remote_sdp = self._invitation.sdp.proposed_remote connection = SDPConnection(local_sdp.address) stream_map = dict((stream.index, stream) for stream in streams) for index, media in enumerate(remote_sdp.media): stream = stream_map.get(index, None) if stream is not None: media = stream.get_local_media(remote_sdp=remote_sdp, index=index) if not media.has_ice_attributes and not media.has_ice_candidates: media.connection = connection if index < len(local_sdp.media): local_sdp.media[index] = media else: local_sdp.media.append(media) elif index >= len(local_sdp.media): # actually == is sufficient media = SDPMediaStream.new(media) media.connection = connection media.port = 0 media.attributes = [] media.bandwidth_info = [] local_sdp.media.append(media) self._invitation.send_response(200, sdp=local_sdp) prev_on_hold_streams = set(stream for stream in self.streams if stream.hold_supported and stream.on_hold_by_remote) received_invitation_state = False received_sdp_update = False while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) else: self._fail_proposal(originator='remote', error='SDP negotiation failed: %s' % notification.data.error) return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) on_hold_streams = set(stream for stream in self.streams if stream.hold_supported and stream.on_hold_by_remote) if on_hold_streams != prev_on_hold_streams: hold_supported_streams = (stream for stream in self.streams if stream.hold_supported) notification_center.post_notification('SIPSessionDidChangeHoldState', self, NotificationData(originator='remote', on_hold=bool(on_hold_streams), partial=bool(on_hold_streams) and any(not stream.on_hold_by_remote for stream in hold_supported_streams))) for stream in streams: # TODO: check if port is 0 in local_sdp. In that case PJSIP disabled the stream becuase it couldn't # negotiation failed. If there are more streams, however, the negotiation is considered successful as a # whole, so while we built a normal SDP, PJSIP modified it and sent it to the other side. That's kind io # OK, but we cannot really start the stream. -Saul stream.start(local_sdp, remote_sdp, stream.index) with api.timeout(self.media_stream_timeout): wait_count = len(streams) while wait_count > 0 or self._channel: notification = self._channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 else: unhandled_notifications.append(notification) except api.TimeoutError: self._fail_proposal(originator='remote', error='media stream timed out while starting') except MediaStreamDidNotInitializeError as e: self._fail_proposal(originator='remote', error='media stream did not initialize: {.data.reason}'.format(e)) except MediaStreamDidFailError as e: self._fail_proposal(originator='remote', error='media stream failed: {.data.reason}'.format(e)) except InvitationDisconnectedError as e: self._fail_proposal(originator='remote', error='session ended') notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) except SIPCoreError as e: self._fail_proposal(originator='remote', error='SIP core error: %s' % str(e)) else: proposed_streams = self.proposed_streams self.proposed_streams = None self.streams = self.streams + streams self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalAccepted', self, NotificationData(originator='remote', accepted_streams=streams, proposed_streams=proposed_streams)) notification_center.post_notification('SIPSessionDidRenegotiateStreams', self, NotificationData(originator='remote', added_streams=streams, removed_streams=[])) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None @transition_state('received_proposal', 'rejecting_proposal') @run_in_green_thread def reject_proposal(self, code=488, reason=None): self.greenlet = api.getcurrent() notification_center = NotificationCenter() try: self._invitation.send_response(code, reason) with api.timeout(1, None): while True: notification = self._channel.wait() if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': break except SIPCoreError as e: self._fail_proposal(originator='remote', error='SIP core error: %s' % str(e)) else: proposed_streams = self.proposed_streams self.proposed_streams = None self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='remote', code=code, reason=sip_status_messages[code], proposed_streams=proposed_streams)) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None def add_stream(self, stream): self.add_streams([stream]) @transition_state('connected', 'sending_proposal') @run_in_green_thread def add_streams(self, streams): streams = list(set(streams).difference(self.streams)) if not streams: self.state = 'connected' return self.greenlet = api.getcurrent() notification_center = NotificationCenter() settings = SIPSimpleSettings() unhandled_notifications = [] self.proposed_streams = streams for stream in self.proposed_streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='outgoing') try: wait_count = len(self.proposed_streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 elif notification.name == 'SIPInvitationChangedState': # This is actually the only reason for which this notification could be received if notification.data.state == 'connected' and notification.data.sub_state == 'received_proposal': self._fail_proposal(originator='local', error='received stream proposal') self.handle_notification(notification) return local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for stream in self.proposed_streams: # Try to reuse a disabled media stream to avoid an ever-growing SDP try: index = next(index for index, media in enumerate(local_sdp.media) if media.port == 0) reuse_media = True except StopIteration: index = len(local_sdp.media) reuse_media = False stream.index = index media = stream.get_local_media(remote_sdp=None, index=index) if reuse_media: local_sdp.media[index] = media else: local_sdp.media.append(media) self._invitation.send_reinvite(sdp=local_sdp) notification_center.post_notification('SIPSessionNewProposal', sender=self, data=NotificationData(originator='local', proposed_streams=self.proposed_streams[:])) received_invitation_state = False received_sdp_update = False try: with api.timeout(settings.sip.invite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for s in self.streams: s.update(local_sdp, remote_sdp, s.index) else: self._fail_proposal(originator='local', error='SDP negotiation failed: %s' % notification.data.error) return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True if notification.data.code >= 300: proposed_streams = self.proposed_streams for stream in proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='local', code=notification.data.code, reason=notification.data.reason, proposed_streams=proposed_streams)) return elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except api.TimeoutError: self.cancel_proposal() return accepted_streams = [] for stream in self.proposed_streams: try: remote_media = remote_sdp.media[stream.index] except IndexError: self._fail_proposal(originator='local', error='SDP media missing in answer') return else: if remote_media.port: stream.start(local_sdp, remote_sdp, stream.index) accepted_streams.append(stream) else: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() with api.timeout(self.media_stream_timeout): wait_count = len(accepted_streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 except api.TimeoutError: self._fail_proposal(originator='local', error='media stream timed out while starting') except MediaStreamDidNotInitializeError as e: self._fail_proposal(originator='local', error='media stream did not initialize: {.data.reason}'.format(e)) except MediaStreamDidFailError as e: self._fail_proposal(originator='local', error='media stream failed: {.data.reason}'.format(e)) except InvitationDisconnectedError as e: self._fail_proposal(originator='local', error='session ended') notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) except SIPCoreError as e: self._fail_proposal(originator='local', error='SIP core error: %s' % str(e)) else: self.greenlet = None self.state = 'connected' self.streams += accepted_streams proposed_streams = self.proposed_streams self.proposed_streams = None any_stream_ice = any(getattr(stream, 'ice_active', False) for stream in accepted_streams) if any_stream_ice: self._reinvite_after_ice() notification_center.post_notification('SIPSessionProposalAccepted', self, NotificationData(originator='local', accepted_streams=accepted_streams, proposed_streams=proposed_streams)) notification_center.post_notification('SIPSessionDidRenegotiateStreams', self, NotificationData(originator='local', added_streams=accepted_streams, removed_streams=[])) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None def remove_stream(self, stream): self.remove_streams([stream]) @transition_state('connected', 'sending_proposal') @run_in_green_thread def remove_streams(self, streams): streams = list(set(streams).intersection(self.streams)) if not streams: self.state = 'connected' return self.greenlet = api.getcurrent() notification_center = NotificationCenter() unhandled_notifications = [] try: local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for stream in streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() self.streams.remove(stream) media = local_sdp.media[stream.index] media.port = 0 media.attributes = [] media.bandwidth_info = [] self._invitation.send_reinvite(sdp=local_sdp) received_invitation_state = False received_sdp_update = False with api.timeout(self.short_reinvite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for s in self.streams: s.update(local_sdp, remote_sdp, s.index) else: # TODO pass elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True if not (200 <= notification.data.code < 300): break elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except InvitationDisconnectedError as e: for stream in streams: stream.end() self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) except (api.TimeoutError, MediaStreamDidFailError, SIPCoreError): for stream in streams: stream.end() self.end() else: for stream in streams: stream.end() self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionDidRenegotiateStreams', self, NotificationData(originator='local', added_streams=[], removed_streams=streams)) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None @transition_state('sending_proposal', 'cancelling_proposal') @run_in_green_thread def cancel_proposal(self): if self.greenlet is not None: api.kill(self.greenlet, api.GreenletExit()) self.greenlet = api.getcurrent() notification_center = NotificationCenter() try: self._invitation.cancel_reinvite() while True: try: notification = self._channel.wait() except MediaStreamDidFailError: continue if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': if notification.data.code == 487: proposed_streams = self.proposed_streams or [] for stream in proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='local', code=notification.data.code, reason=notification.data.reason, proposed_streams=proposed_streams)) elif notification.data.code == 200: self.end() elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) break except SIPCoreError as e: proposed_streams = self.proposed_streams or [] for stream in proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='local', code=0, reason='SIP core error: %s' % str(e), proposed_streams=proposed_streams)) except InvitationDisconnectedError as e: for stream in self.proposed_streams or []: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) else: for stream in self.proposed_streams or []: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.greenlet = None self.state = 'connected' finally: self.greenlet = None if self._hold_in_progress: self._send_hold() @run_in_green_thread def hold(self): if self.on_hold or self._hold_in_progress: return self._hold_in_progress = True streams = (self.streams or []) + (self.proposed_streams or []) if not streams: return for stream in streams: stream.hold() if self.state == 'connected': self._send_hold() @run_in_green_thread def unhold(self): if not self.on_hold and not self._hold_in_progress: return self._hold_in_progress = False streams = (self.streams or []) + (self.proposed_streams or []) if not streams: return for stream in streams: stream.unhold() if self.state == 'connected': self._send_unhold() @run_in_green_thread def end(self): if self.state in (None, 'terminating', 'terminated'): return if self.greenlet is not None: api.kill(self.greenlet, api.GreenletExit()) self.greenlet = None notification_center = NotificationCenter() if self._invitation is None or self._invitation.state is None: # The invitation was not yet constructed self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=487, reason='Session Cancelled', failure_reason='user request', redirect_identities=None)) return invitation_state = self._invitation.state if invitation_state in ('disconnecting', 'disconnected'): return self.greenlet = api.getcurrent() self.state = 'terminating' if invitation_state == 'connected': notification_center.post_notification('SIPSessionWillEnd', self, NotificationData(originator='local')) streams = (self.streams or []) + (self.proposed_streams or []) for stream in streams[:]: try: notification_center.remove_observer(self, sender=stream) except KeyError: streams.remove(stream) else: stream.deactivate() cancelling = invitation_state != 'connected' and self.direction == 'outgoing' try: self._invitation.end(timeout=1) while True: try: notification = self._channel.wait() except MediaStreamDidFailError: continue if notification.name == 'SIPInvitationChangedState' and notification.data.state == 'disconnected': break except SIPCoreError as e: if cancelling: notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=0, reason=None, failure_reason='SIP core error: %s' % str(e), redirect_identities=None)) else: self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator='local', end_reason='SIP core error: %s' % str(e))) except InvitationDisconnectedError as e: # As it weird as it may sound, PJSIP accepts a BYE even without receiving a final response to the INVITE if e.data.prev_state == 'connected': self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator=e.data.originator, end_reason=e.data.disconnect_reason)) elif getattr(e.data, 'method', None) == 'BYE' and e.data.originator == 'remote': notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator=e.data.originator, code=0, reason=None, failure_reason=e.data.disconnect_reason, redirect_identities=None)) else: if e.data.originator == 'remote': code = e.data.code reason = e.data.reason elif e.data.disconnect_reason == 'timeout': code = 408 reason = 'timeout' else: code = 0 reason = None if e.data.originator == 'remote' and code // 100 == 3: redirect_identities = e.data.headers.get('Contact', []) else: redirect_identities = None notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator=e.data.originator, code=code, reason=reason, failure_reason=e.data.disconnect_reason, redirect_identities=redirect_identities)) else: if cancelling: notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=487, reason='Session Cancelled', failure_reason='user request', redirect_identities=None)) else: self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator='local', end_reason='user request')) finally: for stream in streams: stream.end() notification_center.remove_observer(self, sender=self._invitation) self.greenlet = None self.state = 'terminated' @property def local_identity(self): if self._invitation is not None and self._invitation.local_identity is not None: return self._invitation.local_identity else: return self._local_identity @property def peer_address(self): return self._invitation.peer_address if self._invitation is not None else None @property def remote_identity(self): if self._invitation is not None and self._invitation.remote_identity is not None: return self._invitation.remote_identity else: return self._remote_identity @property def remote_user_agent(self): return self._invitation.remote_user_agent if self._invitation is not None else None @property def call_id(self): return self._invitation.call_id if self._invitation is not None else None @property def request_uri(self): return self._invitation.request_uri if self._invitation is not None else None def _cancel_hold(self): notification_center = NotificationCenter() try: self._invitation.cancel_reinvite() while True: try: notification = self._channel.wait() except MediaStreamDidFailError: continue if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': if notification.data.code == 200: self.end() return False elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) break except SIPCoreError: pass except InvitationDisconnectedError as e: self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) return False return True def _send_hold(self): self.state = 'sending_proposal' self.greenlet = api.getcurrent() notification_center = NotificationCenter() unhandled_notifications = [] try: local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for stream in self.streams: local_sdp.media[stream.index] = stream.get_local_media(remote_sdp=None, index=stream.index) self._invitation.send_reinvite(sdp=local_sdp) received_invitation_state = False received_sdp_update = False with api.timeout(self.short_reinvite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) else: # TODO pass elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except InvitationDisconnectedError as e: self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) return except api.TimeoutError: if not self._cancel_hold(): return except SIPCoreError: pass self.greenlet = None self.on_hold = True self.state = 'connected' hold_supported_streams = (stream for stream in self.streams if stream.hold_supported) notification_center.post_notification('SIPSessionDidChangeHoldState', self, NotificationData(originator='local', on_hold=True, partial=any(not stream.on_hold_by_local for stream in hold_supported_streams))) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._hold_in_progress = False else: for stream in self.streams: stream.unhold() self._send_unhold() def _send_unhold(self): self.state = 'sending_proposal' self.greenlet = api.getcurrent() notification_center = NotificationCenter() unhandled_notifications = [] try: local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for stream in self.streams: local_sdp.media[stream.index] = stream.get_local_media(remote_sdp=None, index=stream.index) self._invitation.send_reinvite(sdp=local_sdp) received_invitation_state = False received_sdp_update = False with api.timeout(self.short_reinvite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except InvitationDisconnectedError as e: self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) return except api.TimeoutError: if not self._cancel_hold(): return except SIPCoreError: pass self.greenlet = None self.on_hold = False self.state = 'connected' notification_center.post_notification('SIPSessionDidChangeHoldState', self, NotificationData(originator='local', on_hold=False, partial=False)) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: for stream in self.streams: stream.hold() self._send_hold() def _fail(self, originator, code, reason, error, reason_header=None): notification_center = NotificationCenter() prev_inv_state = self._invitation.state self.state = 'terminating' if prev_inv_state not in (None, 'incoming', 'outgoing', 'early', 'connecting'): notification_center.post_notification('SIPSessionWillEnd', self, NotificationData(originator=originator)) if self._invitation.state not in (None, 'disconnecting', 'disconnected'): try: if self._invitation.direction == 'incoming' and self._invitation.state in ('incoming', 'early'): if 400 <= code <= 699 and reason is not None: self._invitation.send_response(code, extra_headers=[reason_header] if reason_header is not None else []) else: self._invitation.end(extra_headers=[reason_header] if reason_header is not None else []) with api.timeout(1): while True: notification = self._channel.wait() if notification.name == 'SIPInvitationChangedState' and notification.data.state == 'disconnected': break except (api.TimeoutError, SIPCoreError): pass notification_center.remove_observer(self, sender=self._invitation) self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator=originator, code=code, reason=reason, failure_reason=error, redirect_identities=None)) self.greenlet = None def _fail_proposal(self, originator, error): notification_center = NotificationCenter() for stream in self.proposed_streams: try: notification_center.remove_observer(self, sender=stream) except KeyError: # _fail_proposal can be called from reject_proposal, which means the stream will # not have been initialized or the session registered as an observer for it. pass else: stream.deactivate() stream.end() if originator == 'remote' and self._invitation.sub_state == 'received_proposal': try: self._invitation.send_response(488 if self.proposed_streams else 500) except SIPCoreError: pass notification_center.post_notification('SIPSessionHadProposalFailure', self, NotificationData(originator=originator, failure_reason=error, proposed_streams=self.proposed_streams[:])) self.state = 'connected' self.proposed_streams = None self.greenlet = None @run_in_green_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPInvitationChangedState(self, notification): if self.state == 'terminated': return if notification.data.originator == 'remote' and notification.data.state not in ('disconnecting', 'disconnected'): contact_header = notification.data.headers.get('Contact', None) if contact_header and 'isfocus' in contact_header[0].parameters: self.remote_focus = True if self.greenlet is not None: if notification.data.state == 'disconnected' and notification.data.prev_state != 'disconnecting': self._channel.send_exception(InvitationDisconnectedError(notification.sender, notification.data)) else: self._channel.send(notification) else: self.greenlet = api.getcurrent() unhandled_notifications = [] try: if notification.data.state == 'connected' and notification.data.sub_state == 'received_proposal': self.state = 'received_proposal' try: proposed_remote_sdp = self._invitation.sdp.proposed_remote active_remote_sdp = self._invitation.sdp.active_remote if len(proposed_remote_sdp.media) < len(active_remote_sdp.media): engine = Engine() self._invitation.send_response(488, extra_headers=[WarningHeader(399, engine.user_agent, 'Streams cannot be deleted from the SDP')]) self.state = 'connected' return for stream in self.streams: if not stream.validate_update(proposed_remote_sdp, stream.index): engine = Engine() self._invitation.send_response(488, extra_headers=[WarningHeader(399, engine.user_agent, 'Failed to update media stream index %d' % stream.index)]) self.state = 'connected' return added_media_indexes = set() removed_media_indexes = set() reused_media_indexes = set() for index, media_stream in enumerate(proposed_remote_sdp.media): if index >= len(active_remote_sdp.media): added_media_indexes.add(index) elif media_stream.port == 0 and active_remote_sdp.media[index].port > 0: removed_media_indexes.add(index) elif media_stream.port > 0 and active_remote_sdp.media[index].port == 0: reused_media_indexes.add(index) elif media_stream.media != active_remote_sdp.media[index].media: added_media_indexes.add(index) removed_media_indexes.add(index) if added_media_indexes | reused_media_indexes and removed_media_indexes: engine = Engine() self._invitation.send_response(488, extra_headers=[WarningHeader(399, engine.user_agent, 'Both removing AND adding a media stream is currently not supported')]) self.state = 'connected' return elif added_media_indexes | reused_media_indexes: self.proposed_streams = [] for index in added_media_indexes | reused_media_indexes: media_stream = proposed_remote_sdp.media[index] if media_stream.port != 0: for stream_type in MediaStreamRegistry: try: stream = stream_type.new_from_sdp(self, proposed_remote_sdp, index) except UnknownStreamError: continue except InvalidStreamError as e: log.error("Invalid stream: {}".format(e)) break except Exception as e: log.exception("Exception occurred while setting up stream from SDP: {}".format(e)) break else: stream.index = index self.proposed_streams.append(stream) break if self.proposed_streams: self._invitation.send_response(100) notification.center.post_notification('SIPSessionNewProposal', sender=self, data=NotificationData(originator='remote', proposed_streams=self.proposed_streams[:])) else: self._invitation.send_response(488) self.state = 'connected' return else: local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 removed_streams = [stream for stream in self.streams if stream.index in removed_media_indexes] prev_on_hold_streams = set(stream for stream in self.streams if stream.hold_supported and stream.on_hold_by_remote) for stream in removed_streams: notification.center.remove_observer(self, sender=stream) stream.deactivate() media = local_sdp.media[stream.index] media.port = 0 media.attributes = [] media.bandwidth_info = [] for stream in self.streams: local_sdp.media[stream.index] = stream.get_local_media(remote_sdp=proposed_remote_sdp, index=stream.index) try: self._invitation.send_response(200, sdp=local_sdp) except PJSIPError: for stream in removed_streams: self.streams.remove(stream) stream.end() if removed_streams: self.end() return else: try: self._invitation.send_response(488) except PJSIPError: self.end() return else: for stream in removed_streams: self.streams.remove(stream) stream.end() received_invitation_state = False received_sdp_update = False while not received_sdp_update or not received_invitation_state or self._channel: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) else: # TODO pass elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) else: unhandled_notifications.append(notification) else: unhandled_notifications.append(notification) on_hold_streams = set(stream for stream in self.streams if stream.hold_supported and stream.on_hold_by_remote) if on_hold_streams != prev_on_hold_streams: hold_supported_streams = (stream for stream in self.streams if stream.hold_supported) notification.center.post_notification('SIPSessionDidChangeHoldState', self, NotificationData(originator='remote', on_hold=bool(on_hold_streams), partial=bool(on_hold_streams) and any(not stream.on_hold_by_remote for stream in hold_supported_streams))) if removed_media_indexes: notification.center.post_notification('SIPSessionDidRenegotiateStreams', self, NotificationData(originator='remote', added_streams=[], removed_streams=removed_streams)) except InvitationDisconnectedError as e: self.greenlet = None self.state = 'connected' notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = NotificationCenter() self.handle_notification(notification) except SIPCoreError: self.end() else: self.state = 'connected' elif notification.data.state == 'connected' and notification.data.sub_state == 'received_proposal_request': self.state = 'received_proposal_request' try: # An empty proposal was received, generate an offer self._invitation.send_response(100) local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 connection_address = host.outgoing_ip_for(self._invitation.peer_address.ip) if local_sdp.connection is not None: local_sdp.connection.address = connection_address for index, stream in enumerate(self.streams): stream.reset(index) media = stream.get_local_media(remote_sdp=None, index=index) if media.connection is not None: media.connection.address = connection_address local_sdp.media[stream.index] = media self._invitation.send_response(200, sdp=local_sdp) received_invitation_state = False received_sdp_update = False while not received_sdp_update or not received_invitation_state or self._channel: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) else: # TODO pass elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) else: unhandled_notifications.append(notification) else: unhandled_notifications.append(notification) except InvitationDisconnectedError as e: self.greenlet = None self.state = 'connected' notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = NotificationCenter() self.handle_notification(notification) except SIPCoreError: raise # FIXME else: self.state = 'connected' elif notification.data.prev_state == notification.data.state == 'connected' and notification.data.prev_sub_state == 'received_proposal' and notification.data.sub_state == 'normal': if notification.data.originator == 'local' and notification.data.code == 487: self.state = 'connected' proposed_streams = self.proposed_streams self.proposed_streams = None notification.center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='remote', code=notification.data.code, reason=notification.data.reason, proposed_streams=proposed_streams)) if self._hold_in_progress: self._send_hold() elif notification.data.state == 'disconnected': if self.state == 'incoming': self.state = 'terminated' if notification.data.originator == 'remote': notification.center.post_notification('SIPSessionDidFail', self, NotificationData(originator='remote', code=487, reason='Session Cancelled', failure_reason=notification.data.disconnect_reason, redirect_identities=None)) else: # There must have been an error involved notification.center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=0, reason=None, failure_reason=notification.data.disconnect_reason, redirect_identities=None)) else: notification.center.post_notification('SIPSessionWillEnd', self, NotificationData(originator=notification.data.originator)) for stream in self.streams: notification.center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.state = 'terminated' self.end_time = ISOTimestamp.now() notification.center.post_notification('SIPSessionDidEnd', self, NotificationData(originator=notification.data.originator, end_reason=notification.data.disconnect_reason)) notification.center.remove_observer(self, sender=self._invitation) finally: self.greenlet = None for notification in unhandled_notifications: self.handle_notification(notification) def _NH_SIPInvitationGotSDPUpdate(self, notification): if self.greenlet is not None: self._channel.send(notification) def _NH_SIPInvitationTransferNewIncoming(self, notification): self._invitation.notify_transfer_progress(500) def _NH_RTPStreamDidEnableEncryption(self, notification): if notification.sender.type != 'audio': return audio_stream = notification.sender if audio_stream.encryption.type == 'ZRTP': # start ZRTP on the video stream, if applicable try: video_stream = next(stream for stream in self.streams or [] if stream.type=='video') except StopIteration: return if video_stream.encryption.type == 'ZRTP' and not video_stream.encryption.active: video_stream.encryption.zrtp._enable(audio_stream) def _NH_MediaStreamDidStart(self, notification): stream = notification.sender if stream.type == 'audio' and stream.encryption.type == 'ZRTP': stream.encryption.zrtp._enable() elif stream.type == 'video' and stream.encryption.type == 'ZRTP': # start ZRTP on the video stream, if applicable try: audio_stream = next(stream for stream in self.streams or [] if stream.type=='audio') except StopIteration: pass else: if audio_stream.encryption.type == 'ZRTP' and audio_stream.encryption.active: stream.encryption.zrtp._enable(audio_stream) if self.greenlet is not None: self._channel.send(notification) def _NH_MediaStreamDidInitialize(self, notification): if self.greenlet is not None: self._channel.send(notification) def _NH_MediaStreamDidNotInitialize(self, notification): if self.greenlet is not None and self.state not in ('terminating', 'terminated'): self._channel.send_exception(MediaStreamDidNotInitializeError(notification.sender, notification.data)) def _NH_MediaStreamDidFail(self, notification): if self.greenlet is not None: if self.state not in ('terminating', 'terminated'): self._channel.send_exception(MediaStreamDidFailError(notification.sender, notification.data)) else: stream = notification.sender if self.streams == [stream]: self.end() else: try: self.remove_stream(stream) except IllegalStateError: self.end() -class SessionManager(object): - __metaclass__ = Singleton - implements(IObserver) +@implementer(IObserver) +class SessionManager(object, metaclass=Singleton): def __init__(self): self.sessions = [] self.state = None self._channel = coros.queue() def start(self): self.state = 'starting' notification_center = NotificationCenter() notification_center.post_notification('SIPSessionManagerWillStart', sender=self) notification_center.add_observer(self, 'SIPInvitationChangedState') notification_center.add_observer(self, 'SIPSessionNewIncoming') notification_center.add_observer(self, 'SIPSessionNewOutgoing') notification_center.add_observer(self, 'SIPSessionDidFail') notification_center.add_observer(self, 'SIPSessionDidEnd') self.state = 'started' notification_center.post_notification('SIPSessionManagerDidStart', sender=self) def stop(self): self.state = 'stopping' notification_center = NotificationCenter() notification_center.post_notification('SIPSessionManagerWillEnd', sender=self) for session in self.sessions: session.end() while self.sessions: self._channel.wait() notification_center.remove_observer(self, 'SIPInvitationChangedState') notification_center.remove_observer(self, 'SIPSessionNewIncoming') notification_center.remove_observer(self, 'SIPSessionNewOutgoing') notification_center.remove_observer(self, 'SIPSessionDidFail') notification_center.remove_observer(self, 'SIPSessionDidEnd') self.state = 'stopped' notification_center.post_notification('SIPSessionManagerDidEnd', sender=self) @run_in_twisted_thread def handle_notification(self, notification): if notification.name == 'SIPInvitationChangedState' and notification.data.state == 'incoming': account_manager = AccountManager() account = account_manager.find_account(notification.data.request_uri) if account is None: account = DefaultAccount() notification.sender.send_response(100) session = Session(account) session.init_incoming(notification.sender, notification.data) elif notification.name in ('SIPSessionNewIncoming', 'SIPSessionNewOutgoing'): self.sessions.append(notification.sender) elif notification.name in ('SIPSessionDidFail', 'SIPSessionDidEnd'): self.sessions.remove(notification.sender) if self.state == 'stopping': self._channel.send(notification) diff --git a/sylk/streams.py b/sylk/streams.py index 90c0c16..c1ae209 100644 --- a/sylk/streams.py +++ b/sylk/streams.py @@ -1,384 +1,388 @@ import random from collections import defaultdict from functools import partial from application.notification import NotificationCenter, NotificationData from eventlib import api from eventlib.coros import queue from eventlib.proc import spawn, ProcExit from msrplib.connect import DirectConnector, DirectAcceptor from msrplib.protocol import URI, FailureReportHeader, SuccessReportHeader, UseNicknameHeader from msrplib.session import contains_mime_type, MSRPSession from msrplib.transport import make_response from sipsimple.core import SDPAttribute from sipsimple.payloads import ParserError from sipsimple.payloads.iscomposing import IsComposingDocument, State, LastActive, Refresh, ContentType from sipsimple.streams import InvalidStreamError, UnknownStreamError from sipsimple.streams.msrp import MSRPStreamBase as _MSRPStreamBase, MSRPStreamError, NotificationProxyLogger from sipsimple.streams.msrp.chat import ChatStream as _ChatStream, ChatStreamError, ChatIdentity, Message, QueuedMessage, CPIMPayload, CPIMParserError from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import run_in_green_thread from sipsimple.util import ISOTimestamp from sylk.configuration import SIPConfig @run_in_green_thread def MSRPStreamBase_initialize(self, session, direction): self.greenlet = api.getcurrent() notification_center = NotificationCenter() notification_center.add_observer(self, sender=self) try: self.session = session self.transport = self.session.account.msrp.transport outgoing = direction=='outgoing' logger = NotificationProxyLogger() if self.session.account.msrp.connection_model == 'relay': if not outgoing and self.remote_role in ('actpass', 'passive'): # 'passive' not allowed by the RFC but play nice for interoperability. -Saul self.msrp_connector = DirectConnector(logger=logger, use_sessmatch=True) self.local_role = 'active' elif not outgoing: if self.transport=='tls' and None in (self.session.account.tls_credentials.cert, self.session.account.tls_credentials.key): raise MSRPStreamError("Cannot accept MSRP connection without a TLS certificate") self.msrp_connector = DirectAcceptor(logger=logger) self.local_role = 'passive' else: # outgoing self.msrp_connector = DirectConnector(logger=logger, use_sessmatch=True) self.local_role = 'active' else: if not outgoing and self.remote_role in ('actpass', 'passive'): # 'passive' not allowed by the RFC but play nice for interoperability. -Saul self.msrp_connector = DirectConnector(logger=logger, use_sessmatch=True) self.local_role = 'active' else: if not outgoing and self.transport=='tls' and None in (self.session.account.tls_credentials.cert, self.session.account.tls_credentials.key): raise MSRPStreamError("Cannot accept MSRP connection without a TLS certificate") self.msrp_connector = DirectAcceptor(logger=logger, use_sessmatch=True) self.local_role = 'actpass' if outgoing else 'passive' full_local_path = self.msrp_connector.prepare(local_uri=URI(host=SIPConfig.local_ip.normalized, port=0, use_tls=self.transport=='tls', credentials=self.session.account.tls_credentials)) self.local_media = self._create_local_media(full_local_path) except Exception as e: notification_center.post_notification('MediaStreamDidNotInitialize', self, NotificationData(reason=str(e))) else: notification_center.post_notification('MediaStreamDidInitialize', self) finally: self._initialize_done = True self.greenlet = None # Monkey-patch the initialize method (needed because we want every MSRP based stream to behave this way, including file transfers) # _MSRPStreamBase.initialize = MSRPStreamBase_initialize class ChatStream(_MSRPStreamBase): type = 'chat' priority = _ChatStream.priority + 1 msrp_session_class = MSRPSession media_type = 'message' accept_types = ['message/cpim'] accept_wrapped_types = ['*'] supported_chatroom_capabilities = ['nickname', 'private-messages', 'com.ag-projects.screen-sharing', 'com.ag-projects.zrtp-sas'] def __init__(self): super(ChatStream, self).__init__(direction='sendrecv') self.message_queue = queue() self.sent_messages = set() self.incoming_queue = defaultdict(list) self.message_queue_thread = None @classmethod def new_from_sdp(cls, session, remote_sdp, stream_index): remote_stream = remote_sdp.media[stream_index] - if remote_stream.media != 'message': + if remote_stream.media != b'message': raise UnknownStreamError expected_transport = 'TCP/TLS/MSRP' if session.account.msrp.transport=='tls' else 'TCP/MSRP' - if remote_stream.transport != expected_transport: - raise InvalidStreamError("expected %s transport in chat stream, got %s" % (expected_transport, remote_stream.transport)) - if remote_stream.formats != ['*']: + if remote_stream.transport != expected_transport.encode(): + raise InvalidStreamError("expected %s transport in chat stream, got %s" % (expected_transport, remote_stream.transport.decode())) + if remote_stream.formats != [b'*']: raise InvalidStreamError("wrong format list specified") stream = cls() stream.remote_role = remote_stream.attributes.getfirst('setup', 'active') - if remote_stream.direction != 'sendrecv': + if remote_stream.direction != b'sendrecv': raise InvalidStreamError("Unsupported direction for chat stream: %s" % remote_stream.direction) - remote_accept_types = remote_stream.attributes.getfirst('accept-types') + remote_accept_types = remote_stream.attributes.getfirst(b'accept-types') if remote_accept_types is None: raise InvalidStreamError("remote SDP media does not have 'accept-types' attribute") - if not any(contains_mime_type(cls.accept_types, mime_type) for mime_type in remote_accept_types.split()): + if not any(contains_mime_type(cls.accept_types, mime_type) for mime_type in remote_accept_types.decode().split()): raise InvalidStreamError("no compatible media types found") return stream @property def local_identity(self): try: return ChatIdentity(self.session.local_identity.uri, self.session.local_identity.display_name) except AttributeError: return None @property def remote_identity(self): try: return ChatIdentity(self.session.remote_identity.uri, self.session.remote_identity.display_name) except AttributeError: return None @property def private_messages_allowed(self): return 'private-messages' in self.chatroom_capabilities @property def nickname_allowed(self): return 'nickname' in self.chatroom_capabilities @property def chatroom_capabilities(self): try: if self.session.local_focus: return ' '.join(self.local_media.attributes.getall('chatroom')).split() elif self.session.remote_focus: return ' '.join(self.remote_media.attributes.getall('chatroom')).split() except AttributeError: pass return [] def _NH_MediaStreamDidStart(self, notification): self.message_queue_thread = spawn(self._message_queue_handler) def _NH_MediaStreamDidNotInitialize(self, notification): message_queue, self.message_queue = self.message_queue, queue() while message_queue: message = message_queue.wait() if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream was closed') notification.center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) def _NH_MediaStreamDidEnd(self, notification): if self.message_queue_thread is not None: self.message_queue_thread.kill() else: message_queue, self.message_queue = self.message_queue, queue() while message_queue: message = message_queue.wait() if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream ended') notification.center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) def _create_local_media(self, uri_path): local_media = super(ChatStream, self)._create_local_media(uri_path) if self.session.local_focus and self.supported_chatroom_capabilities: - local_media.attributes.append(SDPAttribute('chatroom', ' '.join(self.supported_chatroom_capabilities))) + caps = ' '.join(self.supported_chatroom_capabilities) + local_media.attributes.append(SDPAttribute(b'chatroom', caps.encode())) return local_media def _handle_REPORT(self, chunk): # in theory, REPORT can come with Byte-Range which would limit the scope of the REPORT to the part of the message. if chunk.message_id in self.sent_messages: self.sent_messages.remove(chunk.message_id) notification_center = NotificationCenter() data = NotificationData(message_id=chunk.message_id, message=chunk, code=chunk.status.code, reason=chunk.status.comment) if chunk.status.code == 200: notification_center.post_notification('ChatStreamDidDeliverMessage', sender=self, data=data) else: notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) def _handle_SEND(self, chunk): # This ChatStream doesn't send MSRP REPORT chunks automatically, the developer needs to manually send them if chunk.size == 0: # keep-alive self.msrp_session.send_report(chunk, 200, 'OK') return if self.direction == 'sendonly': self.msrp_session.send_report(chunk, 413, 'Unwanted Message') return if chunk.content_type.lower() != 'message/cpim': self.incoming_queue.pop(chunk.message_id, None) self.msrp_session.send_report(chunk, 415, 'Invalid Content-Type') return if chunk.contflag == '#': self.incoming_queue.pop(chunk.message_id, None) self.msrp_session.send_report(chunk, 200, 'OK') return elif chunk.contflag == '+': self.incoming_queue[chunk.message_id].append(chunk.data) self.msrp_session.send_report(chunk, 200, 'OK') return else: - data = ''.join(self.incoming_queue.pop(chunk.message_id, [])) + chunk.data + data = ''.join(self.incoming_queue.pop(chunk.message_id, [])) + chunk.data.decode() + try: payload = CPIMPayload.decode(data) except CPIMParserError: self.msrp_session.send_report(chunk, 400, 'CPIM Parser Error') return + message = Message(**{name: getattr(payload, name) for name in Message.__slots__}) + if not contains_mime_type(self.accept_wrapped_types, message.content_type): self.msrp_session.send_report(chunk, 415, 'Invalid Content-Type') return if message.timestamp is None: message.timestamp = ISOTimestamp.now() if message.sender is None: message.sender = self.remote_identity if payload.charset is not None: message.content = message.content.decode(payload.charset) private = self.session.remote_focus and len(message.recipients) == 1 and message.recipients[0] != self.remote_identity notification_center = NotificationCenter() if message.content_type.lower() == IsComposingDocument.content_type: try: data = IsComposingDocument.parse(message.content) except ParserError as e: self.msrp_session.send_report(chunk, 400, str(e)) return ndata = NotificationData(state=data.state.value, refresh=data.refresh.value if data.refresh is not None else 120, content_type=data.content_type.value if data.content_type is not None else None, last_active=data.last_active.value if data.last_active is not None else None, sender=message.sender, recipients=message.recipients, private=private, chunk=chunk) notification_center.post_notification('ChatStreamGotComposingIndication', self, ndata) else: ndata = NotificationData(message=message, private=private, chunk=chunk) notification_center.post_notification('ChatStreamGotMessage', self, ndata) def _handle_NICKNAME(self, chunk): nickname = chunk.headers['Use-Nickname'].decoded NotificationCenter().post_notification('ChatStreamGotNicknameRequest', self, NotificationData(nickname=nickname, chunk=chunk)) def _on_transaction_response(self, message_id, response): if message_id in self.sent_messages and response.code != 200: self.sent_messages.remove(message_id) data = NotificationData(message_id=message_id, message=response, code=response.code, reason=response.comment) NotificationCenter().post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) def _on_nickname_transaction_response(self, message_id, response): notification_center = NotificationCenter() if response.code == 200: notification_center.post_notification('ChatStreamDidSetNickname', sender=self, data=NotificationData(message_id=message_id, response=response)) else: notification_center.post_notification('ChatStreamDidNotSetNickname', sender=self, data=NotificationData(message_id=message_id, message=response, code=response.code, reason=response.comment)) def _message_queue_handler(self): notification_center = NotificationCenter() try: while True: message = self.message_queue.wait() if self.msrp_session is None: if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream ended') notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) break try: - if isinstance(message.content, unicode): + if isinstance(message.content, str): message.content = message.content.encode('utf8') charset = 'utf8' else: charset = None message.sender = message.sender or self.local_identity message.recipients = message.recipients or [self.remote_identity] message.timestamp = message.timestamp or ISOTimestamp.now() payload = CPIMPayload(charset=charset, **{name: getattr(message, name) for name in Message.__slots__}) except ChatStreamError as e: if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason=e.args[0]) notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) continue else: content, content_type = payload.encode() message_id = message.id notify_progress = message.notify_progress report = 'yes' if notify_progress else 'no' chunk = self.msrp_session.make_message(content, content_type=content_type, message_id=message_id) chunk.add_header(FailureReportHeader(report)) chunk.add_header(SuccessReportHeader(report)) try: self.msrp_session.send_chunk(chunk, response_cb=partial(self._on_transaction_response, message_id)) except Exception as e: if notify_progress: data = NotificationData(message_id=message_id, message=None, code=0, reason=str(e)) notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) except ProcExit: if notify_progress: data = NotificationData(message_id=message_id, message=None, code=0, reason='Stream ended') notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) raise else: if notify_progress: self.sent_messages.add(message_id) notification_center.post_notification('ChatStreamDidSendMessage', sender=self, data=NotificationData(message=chunk)) finally: self.message_queue_thread = None while self.sent_messages: message_id = self.sent_messages.pop() data = NotificationData(message_id=message_id, message=None, code=0, reason='Stream ended') notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) message_queue, self.message_queue = self.message_queue, queue() while message_queue: message = message_queue.wait() if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream ended') notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) @run_in_twisted_thread def _enqueue_message(self, message): if self._done: if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream ended') NotificationCenter().post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) else: self.message_queue.send(message) @run_in_green_thread def _send_nickname_response(self, chunk, code, reason): response = make_response(chunk, code, reason) try: self.msrp_session.send_chunk(response) except Exception: pass def accept_nickname(self, chunk): if chunk.method != 'NICKNAME': raise ValueError('Incorrect chunk method for accept_nickname: %s' % chunk.method) self._send_nickname_response(chunk, 200, 'OK') def reject_nickname(self, chunk, code, reason): if chunk.method != 'NICKNAME': raise ValueError('Incorrect chunk method for accept_nickname: %s' % chunk.method) self._send_nickname_response(chunk, code, reason) def send_message(self, content, content_type='text/plain', sender=None, recipients=None, timestamp=None, additional_headers=None, message_id=None, notify_progress=True): message = QueuedMessage(content, content_type, sender=sender, recipients=recipients, timestamp=timestamp, additional_headers=additional_headers, id=message_id, notify_progress=notify_progress) self._enqueue_message(message) return message.id def send_composing_indication(self, state, refresh=None, last_active=None, sender=None, recipients=None, message_id=None, notify_progress=False): content = IsComposingDocument.create(state=State(state), refresh=Refresh(refresh) if refresh is not None else None, last_active=LastActive(last_active) if last_active is not None else None, content_type=ContentType('text')) message = QueuedMessage(content, IsComposingDocument.content_type, sender=sender, recipients=recipients, id=message_id, notify_progress=notify_progress) self._enqueue_message(message) return message.id @run_in_green_thread def _set_local_nickname(self, nickname, message_id): if self.msrp_session is None: # should we generate ChatStreamDidNotSetNickname here? return chunk = self.msrp.make_request('NICKNAME') - chunk.add_header(UseNicknameHeader(nickname or u'')) + chunk.add_header(UseNicknameHeader(nickname or '')) try: self.msrp_session.send_chunk(chunk, response_cb=partial(self._on_nickname_transaction_response, message_id)) except Exception as e: self._failure_reason = str(e) NotificationCenter().post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context='sending', reason=self._failure_reason)) def set_local_nickname(self, nickname): if not self.nickname_allowed: raise ChatStreamError('Setting nickname is not supported') message_id = '%x' % random.getrandbits(64) self._set_local_nickname(nickname, message_id) return message_id diff --git a/sylk/tls.py b/sylk/tls.py index a147a85..bad8e27 100644 --- a/sylk/tls.py +++ b/sylk/tls.py @@ -1,49 +1,49 @@ from application import log from application.process import process from gnutls.crypto import X509Certificate, X509PrivateKey __all__ = 'Certificate', 'PrivateKey' def file_content(filename): path = process.configuration.file(filename) if path is None: raise Exception("File '%s' does not exist" % filename) try: f = open(path, 'rt') except Exception: raise Exception("File '%s' could not be open" % filename) try: return f.read() finally: f.close() class Certificate(object): """Configuration data type. Used to create a gnutls.crypto.X509Certificate object from a file given in the configuration file.""" def __new__(cls, value): - if isinstance(value, basestring): + if isinstance(value, str): try: return X509Certificate(file_content(value)) except Exception as e: log.warn("Certificate file '%s' could not be loaded: %s" % (value, str(e))) return None else: raise TypeError('value should be a string') class PrivateKey(object): """Configuration data type. Used to create a gnutls.crypto.X509PrivateKey object from a file given in the configuration file.""" def __new__(cls, value): - if isinstance(value, basestring): + if isinstance(value, str): try: return X509PrivateKey(file_content(value)) except Exception as e: log.warn("Private key file '%s' could not be loaded: %s" % (value, str(e))) return None else: raise TypeError('value should be a string') diff --git a/sylk/web.py b/sylk/web.py index f3bd268..e75630e 100644 --- a/sylk/web.py +++ b/sylk/web.py @@ -1,92 +1,90 @@ from application import log from application.python.types import Singleton from klein import Klein from twisted.internet import reactor from twisted.internet.ssl import DefaultOpenSSLContextFactory from twisted.web.resource import Resource, NoResource from twisted.web.server import Site from twisted.web.static import File from sylk import __version__ from sylk.configuration import WebServerConfig import os import twisted.web.server __all__ = 'Klein', 'StaticFileResource', 'WebServer', 'server' # Set the 'Server' header string which Twisted Web will use -twisted.web.server.version = b'SylkServer/%s' % __version__ +twisted.web.server.version = b'SylkServer/%s' % __version__.encode() class StaticFileResource(File): def directoryListing(self): return NoResource('Directory listing not available') class RootResource(Resource): isLeaf = True def render_GET(self, request): request.setHeader('Content-Type', 'text/plain') return 'Welcome to SylkServer!' -class WebServer(object): - __metaclass__ = Singleton - +class WebServer(object, metaclass=Singleton): def __init__(self): self.base = Resource() self.base.putChild('', RootResource()) self.site = Site(self.base, logPath=os.devnull) self.site.noisy = False self.listener = None @property def url(self): return self.__dict__.get('url', '') def register_resource(self, path, resource): self.base.putChild(path, resource) def start(self): interface = WebServerConfig.local_ip port = WebServerConfig.local_port cert_path = WebServerConfig.certificate.normalized if WebServerConfig.certificate else None cert_chain_path = WebServerConfig.certificate_chain.normalized if WebServerConfig.certificate_chain else None if cert_path is not None: if not os.path.isfile(cert_path): log.error('Certificate file %s could not be found' % cert_path) return try: ssl_ctx_factory = DefaultOpenSSLContextFactory(cert_path, cert_path) except Exception: log.exception('Creating TLS context') return if cert_chain_path is not None: if not os.path.isfile(cert_chain_path): log.error('Certificate chain file %s could not be found' % cert_chain_path) return ssl_ctx = ssl_ctx_factory.getContext() try: ssl_ctx.use_certificate_chain_file(cert_chain_path) except Exception: log.exception('Setting TLS certificate chain file') return self.listener = reactor.listenSSL(port, self.site, ssl_ctx_factory, backlog=511, interface=interface) scheme = 'https' else: self.listener = reactor.listenTCP(port, self.site, backlog=511, interface=interface) scheme = 'http' port = self.listener.getHost().port self.__dict__['url'] = '%s://%s:%d' % (scheme, WebServerConfig.hostname or interface.normalized, port) log.info('Web server listening for requests on: %s' % self.url) def stop(self): if self.listener is not None: self.listener.stopListening() server = WebServer()