#!/usr/bin/python3

# Work around a Python 3.11 ssl.SSLContext.verify_mode recursion bug seen on
# some installs (the Python-level setter calls
# super(SSLContext, SSLContext).verify_mode.__set__(self, value), which on
# affected environments resolves back to itself and recurses until
# RecursionError). Capture the underlying C data descriptor from
# _ssl._SSLContext early, before any other import has a chance to clobber it,
# then expose a helper that installs a non-recursive descriptor on
# ssl.SSLContext on demand (a one-shot probe at startup misses the bug
# because the corrupted state is introduced by a later import, so we patch
# right before each network call instead).
def _make_safe_verify_mode_descriptor():
    try:
        import ssl
        import _ssl
        base = _ssl._SSLContext.__dict__.get('verify_mode')
        if base is None:
            return None
        cert_none = ssl.CERT_NONE

        class _SafeVerifyMode:
            def __get__(self, instance, owner=None):
                if instance is None:
                    return self
                return base.__get__(instance, owner)

            def __set__(self, instance, value):
                if value == cert_none and instance.check_hostname:
                    raise ValueError(
                        "Cannot set verify_mode to CERT_NONE when "
                        "check_hostname is enabled.")
                base.__set__(instance, value)

        return _SafeVerifyMode()
    except Exception:
        return None

_SAFE_VERIFY_MODE = _make_safe_verify_mode_descriptor()


# Capture stdlib references for ssl as early as possible, before anything
# else has a chance to rebind them (e.g. gevent.monkey.patch_all). On affected
# installs gevent rebinds ssl.SSLContext/SSLSocket to its own subclasses but
# the rebound names are still referenced from stdlib ssl.py's internals
# (super(SSLContext, SSLContext) etc.), which then resolves wrong and either
# recurses (verify_mode) or raises TypeError (SSLSocket._create).
def _capture_stdlib_ssl():
    try:
        import ssl as _stdlib_ssl
    except Exception:
        return {}
    captured = {}
    for name in ('SSLContext', 'SSLSocket', 'SSLError', 'SSLObject',
                 'wrap_socket', 'create_default_context'):
        current = getattr(_stdlib_ssl, name, None)
        if current is not None:
            captured[name] = current
    return captured

_STDLIB_SSL_ORIGINALS = _capture_stdlib_ssl()


def _get_true_stdlib_ssl_attrs():
    """Return the most authoritative stdlib ssl originals we can find.

    Prefers gevent.monkey.saved (which gevent populates when it monkey-
    patches) over our own early capture (which may itself already be the
    rebound version if a sitecustomize/.pth file ran gevent.monkey before
    us)."""
    result = dict(_STDLIB_SSL_ORIGINALS)
    try:
        import gevent.monkey as gm
        saved = getattr(gm, 'saved', {})
        ssl_saved = saved.get('ssl') or {}
        for name, value in ssl_saved.items():
            result[name] = value
    except ImportError:
        pass
    except Exception:
        pass
    # As a last resort, derive the stdlib SSLContext from any gevent subclass.
    if 'SSLContext' in result:
        ctx_cls = result['SSLContext']
        # If it's still gevent's class, walk up the MRO to find the stdlib one.
        if getattr(ctx_cls, '__module__', '') != 'ssl':
            for base in getattr(ctx_cls, '__mro__', ())[1:]:
                if getattr(base, '__module__', '') == 'ssl' and base.__name__ == 'SSLContext':
                    result['SSLContext'] = base
                    break
    if 'SSLSocket' in result:
        sock_cls = result['SSLSocket']
        if getattr(sock_cls, '__module__', '') != 'ssl':
            for base in getattr(sock_cls, '__mro__', ())[1:]:
                if getattr(base, '__module__', '') == 'ssl' and base.__name__ == 'SSLSocket':
                    result['SSLSocket'] = base
                    break
    return result


def _restore_stdlib_ssl_for_request():
    """Temporarily restore stdlib ssl/urllib3 references so requests can
    perform an HTTPS handshake without tripping gevent half-patch bugs.
    Returns a (callable, info) pair; the callable undoes the restoration."""
    try:
        import ssl
    except Exception as e:
        return (lambda: None), 'no ssl module: %s' % e

    originals = _get_true_stdlib_ssl_attrs()
    if not originals:
        return (lambda: None), 'no stdlib originals captured'

    ssl_backup = {}
    changed = []
    for name, original in originals.items():
        current = getattr(ssl, name, None)
        if current is not original:
            ssl_backup[name] = current
            try:
                setattr(ssl, name, original)
                changed.append(name)
            except Exception:
                pass

    urllib3_backup = []
    try:
        import urllib3.util.ssl_ as u3ssl
        for name in ('SSLContext', 'SSLError', 'SSLSocket'):
            target = getattr(ssl, name, None)
            current = getattr(u3ssl, name, None)
            if target is not None and current is not target:
                urllib3_backup.append((u3ssl, name, current))
                try:
                    setattr(u3ssl, name, target)
                except Exception:
                    pass
    except ImportError:
        pass
    except Exception:
        pass

    def restore():
        for module, name, value in urllib3_backup:
            try:
                setattr(module, name, value)
            except Exception:
                pass
        for name, value in ssl_backup.items():
            try:
                setattr(ssl, name, value)
            except Exception:
                pass

    return restore, 'restored stdlib ssl attrs: %s' % changed

def _ensure_sslcontext_verify_mode_safe():
    """Re-install the safe verify_mode descriptor on every Python subclass of
    _ssl._SSLContext, then probe it. Called right before any network I/O that
    might trigger the recursive setter.

    Walking subclasses is required because ssl.SSLContext may have been
    replaced or further subclassed by a third-party library after import
    time; the recursion happens in whichever class's __dict__ still holds
    the broken Python property."""
    if _SAFE_VERIFY_MODE is None:
        return False, 'no base descriptor captured'
    import ssl
    import _ssl

    def _walk(cls, seen):
        if cls in seen:
            return
        seen.add(cls)
        yield cls
        try:
            children = cls.__subclasses__()
        except Exception:
            children = []
        for sub in children:
            yield from _walk(sub, seen)

    patched = []
    seen = set()
    for cls in _walk(_ssl._SSLContext, seen):
        if cls is _ssl._SSLContext:
            continue
        existing = cls.__dict__.get('verify_mode')
        if existing is _SAFE_VERIFY_MODE:
            continue
        if existing is None:
            continue
        # Only replace Python-level descriptors (e.g. the broken property);
        # leave C-level slot descriptors alone.
        if isinstance(existing, property) or (
            type(existing).__module__ != 'builtins'
            and type(existing).__name__ not in ('getset_descriptor', 'member_descriptor')
        ):
            try:
                cls.verify_mode = _SAFE_VERIFY_MODE
                patched.append(cls)
            except Exception as e:
                return False, 'failed to patch %s: %s' % (cls, e)

    # Also make sure the currently-bound ssl.SSLContext has the safe one.
    if ssl.SSLContext.__dict__.get('verify_mode') is not _SAFE_VERIFY_MODE:
        try:
            ssl.SSLContext.verify_mode = _SAFE_VERIFY_MODE
            patched.append(ssl.SSLContext)
        except Exception as e:
            return False, 'failed to patch ssl.SSLContext: %s' % e

    # Probe.
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    return True, 'patched: %r' % patched

import hashlib
import glob
import os
import re
import signal
import json
import sys
import traceback
import urllib.request, urllib.parse, urllib.error
import random
import requests
import uuid
import warnings

# Silence pgpy's noisy CryptographyDeprecationWarning chatter at import time
# (IDEA / TripleDES / CAST5 / Blowfish moved to cryptography.hazmat.decrepit).
# Must happen before "import pgpy" because the warnings fire from pgpy/constants.py
# during pgpy's own import.
try:
    from cryptography.utils import CryptographyDeprecationWarning
    warnings.filterwarnings('ignore', category=CryptographyDeprecationWarning)
except ImportError:
    pass
warnings.filterwarnings('ignore', module=r'pgpy(\..*)?')
# pgpy emits these UserWarnings with stacklevel pointing at the caller, so the
# module filter above doesn't catch them. Match by message instead.
warnings.filterwarnings('ignore', category=UserWarning,
                        message=r'Selected symmetric algorithm not in key preferences')
warnings.filterwarnings('ignore', category=UserWarning,
                        message=r'Selected compression algorithm not in key preferences')
warnings.filterwarnings('ignore', category=UserWarning,
                        message=r'Selected hash algorithm not in key preferences')

import pgpy
import subprocess
import zlib

from collections import defaultdict
from datetime import datetime, timedelta
from dateutil.tz import tzlocal
from itertools import chain
from lxml import html
from optparse import OptionParser
from pathlib import Path
from threading import Event, Thread, Timer
from time import monotonic, sleep

from application import log
from application.system import makedirs
from application.notification import IObserver, NotificationCenter, NotificationData
from application.python.queue import EventQueue
from application.python import Null
from eventlib import api

from gnutls.errors import GNUTLSError
from gnutls.crypto import X509Certificate, X509PrivateKey

from twisted.internet import reactor
from zope.interface import implementer

try:
    from otr import OTRTransport, OTRState, SMPStatus
    from otr.exceptions import IgnoreMessage, UnencryptedMessage, EncryptedMessageError, OTRError, OTRFinishedError
except ModuleNotFoundError as e:
    print('OTR library missing')
    IgnoreMessage = None
    pass

from sipsimple.core import Engine, FromHeader, Message, RouteHeader, SIPCoreError, SIPURI, ToHeader

from sipsimple.account import Account, AccountManager, BonjourAccount
from sipsimple.application import SIPApplication
from sipsimple.audio import WavePlayer
from sipsimple.configuration import ConfigurationError
from sipsimple.configuration.datatypes import VideoResolution
from sipsimple.configuration.settings import SIPSimpleSettings
from sipsimple.core import Route
from sipsimple.core import CORE_REVISION, PJ_VERSION, PJ_SVN_REVISION
try:
    from sipsimple.core import CORE_BUILD
except ImportError:
    CORE_BUILD = 0
from sipsimple import __version__ as version
from sipsimple.lookup import DNSLookup
from sipsimple.payloads.iscomposing import IsComposingMessage, IsComposingDocument, State, Refresh, LastActive, ContentType
from sipsimple.session import IllegalStateError, Session
from sipsimple.streams import MediaStreamRegistry
from sipsimple.streams.msrp.filetransfer import FileSelector
from sipsimple.streams.msrp.chat import CPIMPayload, CPIMHeader, CPIMNamespace, SimplePayload, CPIMParserError, ChatIdentity, OTREncryption, ChatStream
from sipsimple.core import FrameBufferVideoRenderer
from sipclient.video import VideoWindow
from sipsimple.payloads.imdn import IMDNDocument, DisplayNotification, DeliveryNotification

from sipsimple.storage import FileStorage
from sipsimple.threading.green import run_in_green_thread
from sipsimple.util import ISOTimestamp

from sipclient.configuration import config_directory
from sipclient.configuration.account import AccountExtension, BonjourAccountExtension
from sipclient.configuration.datatypes import ResourcePath
from sipclient.configuration.settings import SIPSimpleSettingsExtension
from sipclient.log import Logger
from sipclient import radio_ptt
from sipclient.system import IPAddressMonitor, copy_default_certificates
from sipclient.ui import Prompt, Question, RichText, UI


# This is a helper function for sending formatted notice messages
# OTR commands, hidden when the account has it off. Kept in one place
# because the command dispatcher and the help screen have to agree: a
# command the help does not list but the dispatcher still runs is worse
# than one that is simply absent.
OTR_COMMANDS = ('otr', 'otr_secret', 'otr_answer', 'otr_question')


def public_key_short_checksum(armored_key):
    """The 8-character PGP key ID Blink and Sylk Mobile show, or None.

    Deliberately byte-identical to Blink's MessageHost.public_key_short_checksum
    and to generateShortChecksum in the mobile app: SHA-256 over the
    armoured key with line endings normalised and the ends trimmed, first
    8 hex characters, upper case. The whole point of it is that a person
    reads it off two devices and compares, so any difference in how it is
    derived defeats it.

    Not the same thing as account.sms.public_key_checksum, which is a
    SHA-1 over the same text used only to notice that a key changed.
    """
    if armored_key is None:
        return None
    if isinstance(armored_key, bytes):
        armored_key = armored_key.decode('utf-8', 'replace')
    normalized = armored_key.replace('\r\n', '\n').strip()
    if not normalized:
        return None
    return hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:8].upper()


def read_key_id(path):
    """The key ID of the armoured key in a file, or None."""
    try:
        with open(path, 'rb') as fd:
            return public_key_short_checksum(fd.read())
    except (OSError, IOError) as e:
        show_notice('Cannot read the PGP key at %s: %s' % (path, e))
        return None


def otr_enabled_for(account):
    """Whether OTR may be used at all for this account.

    Off means off in both directions: this program neither offers OTR nor
    answers an offer of one, the commands that drive it are hidden, and
    incoming ?OTR: traffic is left as the unreadable text it is rather
    than fed to a session we are not running. A missing OTR library is
    the same answer reached a different way.
    """
    if IgnoreMessage is None:
        return False
    return bool(getattr(getattr(account, 'sms', None), 'enable_otr', False))


def show_notice(text, bold=True):
    ui = UI()
    if isinstance(text, list):
        ui.writelines([RichText(line, bold=bold) if not isinstance(line, RichText) else line for line in text])
    elif isinstance(text, RichText):
        ui.write(text)
    else:
        ui.write(RichText(text, bold=bold))


# SIP Call-ID resolver — same call_id format Sylk-Mobile's [ui] lines
# use so a single grep through both metro.log and sip-session3's log
# correlates the call end-to-end. Falls back through dialog_id →
# _invitation → '?' so it works for both established and pre-INVITE
# sessions.
def _session_supports_contact_parameters():
    # python3-sipsimple gained Session.connect(contact_parameters=...) for the
    # radio PTT feature tag; older installs only get accept-wrapped-types.
    import inspect
    try:
        return 'contact_parameters' in inspect.signature(Session.connect).parameters
    except (TypeError, ValueError):
        return False


def _sip_call_id(session):
    if session is None:
        return '?'
    try:
        did = getattr(session, 'dialog_id', None)
        if did is not None and getattr(did, 'call_id', None):
            return did.call_id
    except Exception:
        pass
    try:
        inv = getattr(session, '_invitation', None)
        if inv is not None and getattr(inv, 'call_id', None):
            return inv.call_id
    except Exception:
        pass
    return '?'


# Utility classes
#

# Sylk-ZRTP-over-MESSAGE moved into the SDK. The full state machine,
# X25519+HKDF derivation, AEAD install on streams, and the X-Sylk-ZRTP
# capability header are now handled inside sipsimple.streams.rtp.sylk_zrtp
# and wired into Session automatically when account.rtp.encryption is
# opportunistic or zrtp. Applications observe the new
# SIPSessionSylkZRTPStateChanged notification if they want to surface
# the SAS or a "secure" pill — see _NH_SIPSessionSylkZRTPStateChanged
# in SIPSessionApplication below for an example.



class BonjourNeighbour(object):
    def __init__(self, neighbour, uri, display_name, host):
        self.display_name = display_name
        self.host = host
        self.neighbour = neighbour
        self.uri = uri


class RTPStatisticsThread(Thread):
    INTERVAL = 2   # seconds between samples (also drives the video HUD refresh)

    def __init__(self, console=True):
        Thread.__init__(self)
        self.setDaemon(True)
        self.stopped = False
        # When False, suppress console RTP-stats prints; HUD pushes
        # still happen.  `/rtp on/off` flips this.
        self.console = console
        # per-stream byte counters for bandwidth deltas, keyed by id(stream)
        self._last_bytes = {}

    @staticmethod
    def _bytes(direction):
        # sipsimple statistics may use 'bytes' or 'octets' depending on version
        return direction.get('bytes', direction.get('octets', 0)) or 0

    def _bandwidth_kbps(self, stream, stats):
        key = id(stream)
        rx_now = self._bytes(stats['rx'])
        tx_now = self._bytes(stats['tx'])
        rx_prev, tx_prev, t_prev = self._last_bytes.get(key, (rx_now, tx_now, None))
        from time import monotonic
        t_now = monotonic()
        self._last_bytes[key] = (rx_now, tx_now, t_now)
        if t_prev is None:
            return 0.0, 0.0
        dt = max(t_now - t_prev, 0.001)
        return (rx_now - rx_prev) * 8 / 1000.0 / dt, (tx_now - tx_prev) * 8 / 1000.0 / dt

    @staticmethod
    def _video_resolution(stream):
        # Try a few attribute paths that different sipsimple versions expose.
        for path in (('producer', 'size'), ('producer', 'frame_size'),
                     ('producer', 'resolution'), ('resolution',)):
            obj = stream
            try:
                for attr in path:
                    obj = getattr(obj, attr)
                w = getattr(obj, 'width', None)
                h = getattr(obj, 'height', None)
                if w and h:
                    return '%sx%s' % (w, h)
            except AttributeError:
                continue
        # Fall back to configured outbound resolution
        try:
            res = SIPSimpleSettings().video.resolution
            return '%sx%s' % (res.width, res.height)
        except Exception:
            return '?'

    def run(self):
        application = SIPSessionApplication()
        while not self.stopped:
            session = application.active_session
            if session is not None and session.streams:
                lines = []
                ts = datetime.now().replace(microsecond=0)
                seen = set()
                for stream in session.streams:
                    if stream.type not in ('audio', 'video'):
                        continue
                    stats = stream.statistics
                    if stats is None:
                        continue
                    seen.add(id(stream))
                    rx_kbps, tx_kbps = self._bandwidth_kbps(stream, stats)
                    rx_pkts = stats['rx']['packets']
                    loss = 100.0 * stats['rx']['packets_lost'] / rx_pkts if rx_pkts else 0
                    base = ('%s RTP %s: RTT=%d ms, loss=%.1f%%, '
                            'jitter RX/TX=%d/%d ms, bw RX/TX=%.0f/%.0f kbps' % (
                                ts, stream.type,
                                stats['rtt']['avg'] / 1000,
                                loss,
                                stats['rx']['jitter']['avg'] / 1000,
                                stats['tx']['jitter']['avg'] / 1000,
                                rx_kbps, tx_kbps))
                    if stream.type == 'video':
                        base += ', resolution=%s' % self._video_resolution(stream)
                        # Push the same numbers to the on-screen video
                        # HUD if a window is open for this session.
                        # RTT is a path-level metric shared by every stream
                        # of the call, but pjmedia's *video* RTCP RTT is
                        # unreliable and usually stays 0 (the video RR
                        # LSR/DLSR round-trip isn't computed).  The audio
                        # stream's RTCP RTT is solid, so borrow it for the HUD
                        # when the video figure is missing.  Falls back to the
                        # video value (0) only if the call has no audio RTT.
                        rtt_us = stats['rtt']['avg']
                        if not rtt_us:
                            for other in session.streams:
                                if other.type != 'audio':
                                    continue
                                other_stats = other.statistics
                                if other_stats and other_stats['rtt']['avg']:
                                    rtt_us = other_stats['rtt']['avg']
                                break
                        try:
                            for window, _renderer in application.video_windows.get(id(session), []):
                                codec_name = getattr(stream, 'codec', None) or '?'
                                window.update_stats({
                                    'codec': codec_name,
                                    'rtt_ms': rtt_us / 1000.0,
                                    'packet_loss': loss / 100.0,
                                    'bandwidth_kbps': rx_kbps + tx_kbps,
                                    'resolution': self._video_resolution(stream),
                                    'extra': {
                                        'rx_kbps': '%.0f' % rx_kbps,
                                        'tx_kbps': '%.0f' % tx_kbps,
                                    },
                                })
                        except Exception as exc:
                            # HUD push is best-effort; never let it
                            # take down the stats thread.
                            pass
                    lines.append(base)
                # forget counters for streams that no longer exist
                for key in list(self._last_bytes):
                    if key not in seen:
                        del self._last_bytes[key]
                if lines and self.console:
                    reactor.callFromThread(show_notice, lines)
            sleep(self.INTERVAL)

    def stop(self):
        self.stopped = True


class QueuedMessage(object):
    def __init__(self, msg_id, content, content_type='text/plain', call_id=None):
        self.id = msg_id
        self.content = content
        self.timestamp = None
        self.content_type = content_type
        self.encrypted = False
        self.call_id = None
        self.imdn_id = None  # CPIM Message-ID assigned for IMDN correlation


class OTRInternalMessage(QueuedMessage):
    def __init__(self, content):
        super(OTRInternalMessage, self).__init__('OTR', content, 'text/plain')


@implementer(IObserver)
class MessageSession(object):
    
    def __init__(self, account, target, route=None, silent=False):
        self.account = account
        self.target = target
        # A silent session is one this client opened for its own key
        # machinery (a public key lookup, a key upload to the server),
        # not a conversation the user asked for. It behaves like any
        # other session but says nothing about starting or ending.
        self.silent = silent
        self.routes = None
        self.msg_id = 0
        self.started = False
        self.msg_map = {}
        self.route = None
        self.ended = False
        self.route = route
        self.notification_center = NotificationCenter()

        self.encryption = OTREncryption(self)

        self.message_queue = EventQueue(self._send_message)

        target = self.target 

        if '@' not in target:
            target = '%s@%s' % (target, self.account.id.domain)

        if not target.startswith('sip:') and not target.startswith('sips:'):
            target = 'sip:' + target

        self.remote_uri = str(target).split(":")[1]

        try:
            self.target_uri = SIPURI.parse(target)
        except SIPCoreError:
            show_notice('Illegal SIP URI: %s' % target)
            self.target_uri = None
            return

        self.notification_center = NotificationCenter()

        self.announce()

    def announce(self):
        """Tell the user this message session exists.

        Silent sessions are the client talking to the server on its own
        behalf and are not announced. If one later becomes the user's
        conversation, whoever promotes it calls this.
        """
        if self.silent:
            return
        if self.route:
            show_notice('Message session started with %s via %s' % (self.target, self.route))
        else:
            show_notice('Message session started with %s' % self.target)

    @property
    def otr_enabled(self):
        return otr_enabled_for(self.account)

    def end(self):
        if not self.silent:
            show_notice('Ending message session to %s' % self.target)
        if self.encryption.active:
            self.encryption.stop()

        self.notification_center = None
        self.message_queue = None
        self.encryption = None
        self.ended = True

    def start(self):
        if self.ended:
            return
            
        if self.route:
            self.routes = [self.route]
            if not self.silent:
                show_notice('%s Message session to %s will start via %s' % (datetime.now().replace(microsecond=0), self.remote_uri, self.routes[0]))

            if not self.started:
                self.message_queue.start()
        
            if self.otr_enabled and not self.encryption.active:
                self.encryption.start()

            self.started = True

            return

        lookup = DNSLookup()
        self.notification_center.add_observer(self, sender=lookup)
        settings = SIPSimpleSettings()

        if isinstance(self.account, Account) and self.account.sip.outbound_proxy is not None:
            uri = SIPURI(host=self.account.sip.outbound_proxy.host, port=self.account.sip.outbound_proxy.port, parameters={'transport': self.account.sip.outbound_proxy.transport})
            tls_name = self.account.sip.tls_name or self.account.sip.outbound_proxy.host
        elif isinstance(self.account, Account) and self.account.sip.always_use_my_proxy:
            uri = SIPURI(host=self.account.id.domain)
            tls_name = self.account.sip.tls_name or self.account.id.domain
        else:
            uri = self.remote_uri
            tls_name = uri.host
            if self.account is not BonjourAccount():
                if self.account.id.domain == uri.host.decode():
                    tls_name = self.account.sip.tls_name or self.account.id.domain
                elif "isfocus" in str(uri) and uri.host.decode().endswith(self.account.id.domain):
                    tls_name = self.account.conference.tls_name or self.account.sip.tls_name or self.account.id.domain
            else:
                is_ip_address = re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", uri.host.decode()) or ":" in uri.host.decode()
                if "isfocus" in str(uri) and self.account.conference.tls_name:
                    tls_name = self.account.conference.tls_name
                elif  is_ip_address and  self.account.sip.tls_name:
                    tls_name = self.account.sip.tls_name

        lookup.lookup_sip_proxy(uri, settings.sip.transport_list, tls_name=tls_name)

    def handle_notification(self, notification):
        if self.ended:
            return

        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)


    def inject_otr_message(self, data):
        if not self.otr_enabled:
            return
        if not self.encryption.active:
            ui = UI()
            ui.status = 'Negotiating OTR encryption...'
        messageObject = OTRInternalMessage(data)
        self.send_message(messageObject)

    def _NH_DNSLookupDidSucceed(self, notification):
        self.notification_center.remove_observer(self, sender=notification.sender)

        self.routes = notification.data.result

        if not self.silent:
            show_notice('%s Message session to %s will start via %s' % (datetime.now().replace(microsecond=0), self.remote_uri, self.routes[0]))

        if not self.started:
            self.message_queue.start()
        
        if self.otr_enabled and not self.encryption.active:
            self.encryption.start()

        self.started = True

    def _NH_DNSLookupDidFail(self, notification):
        self.notification_center.remove_observer(self, sender=notification.sender)
        show_notice('%s Message session to %s failed: DNS lookup failed' % (datetime.now().replace(microsecond=0), self.remote_uri))
        self.message_queue.stop()
        
        if not self.routes:
            self.start()

        self.msg_id = self.msg_id + 1

        if not isinstance(message, OTRInternalMessage):
            messageObject = QueuedMessage(self.msg_id , message, content_type)
            self.message_queue.put(messageObject)
        else:
            self.message_queue.put(message)

        return self.msg_id 

    def send_message(self, message, content_type='text/plain', imdn_id=None):
        if not self.routes:
            self.start()

        self.msg_id = self.msg_id + 1

        if not isinstance(message, OTRInternalMessage):
            messageObject = QueuedMessage(self.msg_id , message, content_type)
            messageObject.imdn_id = imdn_id
            self.message_queue.put(messageObject)
        else:
            self.message_queue.put(message)

        return self.msg_id

    def send_composing_indication(self, state, refresh=60, last_active=None):
        """Tell the remote party that we are typing, or that we stopped.

        A state rather than a message: it carries no IMDN, it is exempt
        from encryption in _send_message(), and nothing about it is worth
        putting on the user's screen -- they know they are typing.

        `refresh` is a promise: it says the remote may keep showing the
        indicator for that many seconds without hearing from us again, so
        whoever calls this has to keep calling it while the user types.
        """
        if self.ended or not getattr(self.account.sms, 'enable_composing', True):
            return

        content = IsComposingMessage(state=State(state),
                                     refresh=Refresh(refresh),
                                     last_active=LastActive(last_active or ISOTimestamp.now()),
                                     content_type=ContentType('text')).toxml()
        if isinstance(content, bytes):
            content = content.decode()
        self.send_message(content, content_type=IsComposingDocument.content_type)

    @run_in_green_thread           
    def send_imdn_notification(self, imdn_id, imdn_timestamp, recipient, sender_identity, event):
        #show_notice('Send IMDN %s notification for message %s' % (event, imdn_id))
        if event == 'delivered':
            notification = DeliveryNotification('delivered')
        elif event in ('displayed', 'error'):
            notification = DisplayNotification(event)
        else:
            return

        content = IMDNDocument.create(message_id=imdn_id, datetime=imdn_timestamp, recipient_uri=recipient, notification=notification)
        self.send_message(content.decode(), content_type=IMDNDocument.content_type)
            
    @run_in_green_thread
    def _send_message(self, message):
        if not self.routes:
            return

        # Guard: drop messages whose content was never populated (Null sentinel
        # from application.python, plain None, or empty bytes/str).  Without
        # this, Python 3.13's email.generator rejects Null at as_string() with
        # `TypeError: string payload expected: NullType` and the engine traceback
        # leaks out as a Twisted unhandled error.  The upstream path that lets
        # Null reach here is usually an OTR / PGP encryption branch that
        # silently no-op'd and left message.content un-initialised.
        if message.content is Null or message.content is None or not message.content:
            return

        if not self.route:
            self.route = self.routes.pop(0)

        identity = str(self.account.uri)

        if self.account.display_name:
            identity = '"%s" <%s>' % (self.account.display_name, identity)

        if isinstance(message.content, str):
            charset = 'utf8'
            message.content = message.content.encode(charset)
        else:
            charset = None
            
        what_type = None

        if not isinstance(message, OTRInternalMessage):
            if self.otr_enabled and message.content_type not in (IsComposingDocument.content_type, IMDNDocument.content_type):
                try:
                    otr_out = self.encryption.otr_session.handle_output(message.content, message.content_type)
                    # handle_output() can return Null when OTR is not active
                    # and silently no-op'd; in that case keep the original
                    # plaintext rather than overwriting with the sentinel.
                    if otr_out is not Null and otr_out is not None:
                        message.content = otr_out
                except OTRError as e:
                    if 'has ended the private conversation' in str(e):
                        show_notice('Encryption has been disabled by remote party, please resend the message again')
                        self.encryption.stop()
                    else:
                        show_notice('Failed to encrypt outgoing message: %s' % str(e))
                    return
                except OTRFinishedError:
                    show_notice('Encryption has finished, please resend the message again')
                    return

                if self.encryption.active and not message.content.startswith(b'?OTR:'):
                    show_notice('Encryption has been disabled by remote party, please resend the message again')
                    self.encryption.stop()
                    return None            

        else:
            what_type = 'OTR'

        if message.timestamp is None:
            message.timestamp = ISOTimestamp.now()

        # Optional PGP envelope (matching Blink): encrypt the payload with
        # the remote party's public key, double-encrypted to ourselves when
        # possible so we can re-read our own outgoing messages later. We
        # skip OTR-internal traffic, control-plane content-types, and any
        # message that is already a PGP key.
        pgp_encrypted = False
        if (not isinstance(message, OTRInternalMessage)
                and not self.encryption.active
                and message.content_type not in (
                    IsComposingDocument.content_type,
                    IMDNDocument.content_type,
                    'text/pgp-public-key',
                    'text/pgp-private-key',
                    'application/sylk-api-pgp-key-lookup',
                    'application/sylk-file-transfer',
                )
                and getattr(self.account.sms, 'enable_pgp', True)):
            try:
                application = SIPSessionApplication()
                peer_key = application.get_public_key(self.remote_uri)
            except Exception:
                peer_key = None
            if peer_key is not None:
                try:
                    from pgpy.constants import SymmetricKeyAlgorithm
                    cipher = SymmetricKeyAlgorithm.AES256
                    sessionkey = cipher.gen_key()
                    plaintext = message.content
                    if isinstance(plaintext, bytes):
                        try:
                            plaintext = plaintext.decode('utf-8')
                        except UnicodeDecodeError:
                            plaintext = plaintext.decode('utf-8', errors='replace')
                    pgp_message = pgpy.PGPMessage.new(plaintext)
                    enc = peer_key.encrypt(pgp_message, cipher=cipher, sessionkey=sessionkey)
                    own_key = None
                    try:
                        own_key = application.get_public_key(str(self.account.id))
                    except Exception:
                        own_key = None
                    if own_key is not None:
                        enc = own_key.encrypt(enc, cipher=cipher, sessionkey=sessionkey)
                    del sessionkey
                    message.content = str(enc).encode()
                    pgp_encrypted = True
                except Exception as e:
                    show_notice('PGP encryption failed for outgoing message, sending in clear text: %s' % str(e))

        additional_cpim_headers = []
        additional_sip_headers = []

        if self.account.sms.use_cpim:
            if self.account.sms.enable_imdn and message.content_type != IsComposingDocument.content_type:
                ns = CPIMNamespace('urn:ietf:params:imdn', 'imdn')
                imdn_id = getattr(message, 'imdn_id', None) or str(uuid.uuid4())
                message.imdn_id = imdn_id
                additional_cpim_headers = [CPIMHeader('Message-ID', ns, imdn_id)]

                if message.content_type != IsComposingDocument.content_type:
                    # request IMDN
                    additional_cpim_headers.append(CPIMHeader('Disposition-Notification', ns, 'positive-delivery, display'))

            content_type = 'message/cpim'
            payload = CPIMPayload(message.content,
                                  message.content_type, 
                                  charset='utf-8', 
                                  timestamp=message.timestamp,
                                  sender=ChatIdentity(self.account.uri, self.account.display_name),
                                  recipients=[ChatIdentity(self.remote_uri, None)],
                                  additional_headers=additional_cpim_headers)
        else:
            payload = SimplePayload(message.content, message.content_type, charset)

        content, content_type = payload.encode()

        from_uri = self.account.uri
        if self.account is BonjourAccount():
            settings = SIPSimpleSettings()
            from_uri.parameters['instance_id'] = settings.instance_id

        message_request = Message(FromHeader(from_uri, self.account.display_name), 
                                  ToHeader(self.target_uri),
                                  RouteHeader(self.route.uri),
                                  content_type,
                                  content,
                                  credentials=self.account.credentials,
                                  extra_headers=additional_sip_headers)

        self.notification_center.add_observer(self, sender=message_request)

        self.msg_map[str(message_request)] = message
        message_request.send(15)
        call_id = message_request._request.call_id.decode()
        message.call_id = call_id
        ui = UI()
        
        if message.id != 'OTR':
            if '?OTR:' in content:
                if message.content_type not in (IsComposingDocument.content_type, IMDNDocument.content_type):
                    ui.status = 'Encrypted message sent to %s' % self.route.uri
                    reactor.callLater(2, setattr, ui, 'status', None)
                message.encrypted = True
            else:
                if message.content_type not in (IsComposingDocument.content_type, IMDNDocument.content_type):
                    ui.status = 'Message sent to %s' % (self.route.uri)
                    reactor.callLater(3, setattr, ui, 'status', None)

        else:
            ui.status = 'OTR message %s sent' % call_id
            reactor.callLater(3, setattr, ui, 'status', None)

    def handle_incoming_message(self, from_header, content_type, data, call_id):
        self.msg_id = self.msg_id + 1
        
        cpim_imdn_events = None
        imdn_timestamp = None
        imdn_id = None
        from_header = FromHeader.new(from_header)
        identity = '%s@%s' % (from_header.uri.user.decode(), from_header.uri.host.decode())
        sender_identity = from_header

        if content_type == 'message/cpim':
            try:
                cpim_message = CPIMPayload.decode(data)
            except CPIMParserError as e:
                show_notice('CPIM parse error: %s' % str(e))
                return None
            else:
                content = cpim_message.content
                payload = cpim_message
                content_type = cpim_message.content_type

                #if cpim_message.sender:
                #    identity = '%s@%s' % (cpim_message.sender.uri.user.decode(), cpim_message.sender.uri.host.decode())
                #    if from_header.uri == cpim_message.sender.uri:
                #        if cpim_message.sender.display_name:
                #            identity = '"%s" <%s>' % (cpim_message.sender.display_name, identity)
                #        elif from_header.display_name:
                #            identity = '"%s" <%s>' % (from_header.display_name, identity)
                #else:
                #    identity = from_header
                    
                sender_identity = cpim_message.sender or from_header
                imdn_timestamp = cpim_message.timestamp

                for h in cpim_message.additional_headers:
                    if h.name == "Message-ID":
                        imdn_id = h.value
                    if h.name == "Disposition-Notification":
                        cpim_imdn_events = h.value
                        
                if content_type == IMDNDocument.content_type and content_type not in ('text/pgp-private-key', 'text/pgp-public-key'):
                    document = IMDNDocument.parse(content)
                    imdn_message_id = document.message_id.value
                    imdn_status = document.notification.status.__str__()
                    ui = UI()
                    # Inline tick: ✓ single check for delivered,
                    # ✓✓ double check for displayed/read. Anything
                    # else (error/failed) shows an exclamation mark.
                    if imdn_status == 'delivered':
                        tick = '✓'
                    elif imdn_status == 'displayed':
                        tick = '✓✓'
                    elif imdn_status in ('failed', 'error'):
                        tick = '!'
                    else:
                        tick = imdn_status
                    ui.set_tick(imdn_message_id, tick)
                    return
        else:
            payload = SimplePayload.decode(data, content_type)
            from_header = FromHeader.new(from_header)
            identity = '%s@%s' % (from_header.uri.user.decode(), from_header.uri.host.decode())
            content = payload.content
            content_type = payload.content_type

        if cpim_imdn_events and imdn_timestamp and content_type not in ('text/pgp-private-key', 'text/pgp-public-key', 'application/sylk-file-transfer'):
            if self.account.sms.enable_imdn:
                if 'delivery' in cpim_imdn_events:
                    self.send_imdn_notification(imdn_id, imdn_timestamp, identity, sender_identity, 'delivered')

                
#        if content_type not in (IsComposingDocument.content_type, IMDNDocument.content_type):
        if self.otr_enabled:
            try:
                content = self.encryption.otr_session.handle_input(content, content_type)
            except IgnoreMessage:
                show_notice('OTR message %s received' % call_id)
                return None
            except UnencryptedMessage:
                encrypted = False
                encryption_active = True
            except EncryptedMessageError as e:
                show_notice('OTP encrypted message error: %s' % str(e))
                return None
            except (OTRError, OTRFinishedError) as e:
                show_notice('OTP error: %s' % str(e))
                return None
            else:
                encrypted = encryption_active = self.encryption.active

        if payload.charset is not None:
            content = content.decode(payload.charset)
        elif payload.content_type.startswith('text/'):
            content.decode('utf8')

        return (content_type, content, identity, self.msg_id, encrypted, imdn_id, cpim_imdn_events, imdn_timestamp, sender_identity)

    def _NH_SIPMessageDidSucceed(self, notification):
        self.notification_center.remove_observer(self, sender=notification.sender)

        try:
            message = self.msg_map[str(notification.sender)]
        except KeyError:
            message = None
        else:
            del(self.msg_map[str(notification.sender)])
            
        if not message:
            return

        if message.id in (None, 'OTR'):
            return
        
        if message.content_type in (IsComposingDocument.content_type, IMDNDocument.content_type):
            return
        
        ui = UI()
        
        if notification.data.code == 202:
            ui.status = 'Message %s to %s will be delivered later by the server' % (message.id, self.remote_uri)
            reactor.callLater(4, setattr, ui, 'status', None)
        elif notification.data.code == 200:
            ui.status = 'Message %s received by %s' % (message.id, self.remote_uri)
            reactor.callLater(4, setattr, ui, 'status', None)
        else:
            ui.status = 'Message %s received by %s (code %d)' % (message.id, self.remote_uri, notification.data.code)
            reactor.callLater(4, setattr, ui, 'status', None)

    def _NH_SIPMessageDidFail(self, notification):
        self.notification_center.remove_observer(self, sender=notification.sender)
        try:
            message = self.msg_map[str(notification.sender)]
        except KeyError:
            message = None
        else:
            del(self.msg_map[str(notification.sender)])
            
        if not message:
            return

        if message.id in (None, 'OTR'):
            return

        if hasattr(notification.data, 'headers'):
            server = notification.data.headers.get('Server', Null).body
            client = notification.data.headers.get('Client', Null).body
        else:
            server = 'local'
            client = 'local'

        if notification.data.code == 408 and self.routes:
            pass
            # self.route = self.routes.pop(0)
            # TO DO retry

        if message.encrypted:
            show_notice('%s Encrypted message %s to %s failed on %s: %s (%d)' % (datetime.now().replace(microsecond=0), message.id, self.remote_uri, server or client, notification.data.reason.decode(), notification.data.code))
        else:
            show_notice('%s Message %s to %s failed on %s: %s (%d)' % (datetime.now().replace(microsecond=0), message.id, self.remote_uri,  server or client, notification.data.reason.decode() if isinstance(notification.data.reason, bytes) else notification.data.reason, notification.data.code))


try:
    OTRTransport.register(MessageSession)
except NameError:
    pass

@implementer(IObserver)
class OutgoingCallInitializer(object):

    def __init__(self, account, target, audio=False, chat=False, video=False, play_file=None, auto_reconnect=False, load=False):
        self.account = account
        self.target = target
        self.auto_reconnect = auto_reconnect
        self.load = load
        self.streams = []
        self.play_file = play_file
        self.playback_wave_player = None
        self.play_file = play_file
        application = SIPSessionApplication()
        self.playback_dir = application.playback_dir
        self.remote_identity = None

        if video:
            self.streams.append(MediaStreamRegistry.VideoStream())
        if audio:
            audio_stream = MediaStreamRegistry.AudioStream()
            self.streams.append(audio_stream)
            if self.play_file:
                notification_center = NotificationCenter()
                notification_center.add_observer(self, sender=audio_stream)
        if chat:
            self.streams.append(MediaStreamRegistry.ChatStream())
        self.wave_ringtone = None

    def start(self):
        if self.play_file:
            lock_file = "%s/playback.lock" % self.playback_dir
            Path(lock_file).touch()
            show_notice("Lock file %s created" % lock_file)

        if isinstance(self.account, BonjourAccount) and '@' not in self.target:
            show_notice('Bonjour mode requires a host in the destination address')
            return
        if '@' not in self.target:
            self.target = '%s@%s' % (self.target, self.account.id.domain)
        if not self.target.startswith('sip:') and not self.target.startswith('sips:'):
            self.target = 'sip:' + self.target

        try:
            self.target = SIPURI.parse(self.target)
        except SIPCoreError:
            show_notice('Illegal SIP URI: %s' % self.target)
        else:
            if '.' not in self.target.host.decode() and not isinstance(self.account, BonjourAccount):
                self.target.host = ('%s.%s' % (self.target.host.decode(), self.account.id.domain)).encode()
            lookup = DNSLookup()
            self.lookup = lookup
            SIPSessionApplication().pending_call = self
            notification_center = NotificationCenter()
            notification_center.add_observer(self, sender=lookup)
            settings = SIPSimpleSettings()

            if isinstance(self.account, Account) and self.account.sip.outbound_proxy is not None:
                uri = SIPURI(host=self.account.sip.outbound_proxy.host, port=self.account.sip.outbound_proxy.port, parameters={'transport': self.account.sip.outbound_proxy.transport})
                tls_name = self.account.sip.tls_name or self.account.sip.outbound_proxy.host
            elif isinstance(self.account, Account) and self.account.sip.always_use_my_proxy:
                uri = SIPURI(host=self.account.id.domain)
                tls_name = self.account.sip.tls_name or self.account.id.domain
            else:
                uri = self.target
                tls_name = uri.host
                if self.account is not BonjourAccount():
                    if self.account.id.domain == uri.host.decode():
                        tls_name = self.account.sip.tls_name or self.account.id.domain
                    elif "isfocus" in str(uri) and uri.host.decode().endswith(self.account.id.domain):
                        tls_name = self.account.conference.tls_name or self.account.sip.tls_name or self.account.id.domain
                else:
                    is_ip_address = re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", uri.host.decode()) or ":" in uri.host.decode()
                    if "isfocus" in str(uri) and self.account.conference.tls_name:
                        tls_name = self.account.conference.tls_name
                    elif  is_ip_address and  self.account.sip.tls_name:
                        tls_name = self.account.sip.tls_name
                
            show_notice('DNS lookup for %s' % uri)
            lookup.lookup_sip_proxy(uri, settings.sip.transport_list, tls_name=tls_name)

    def reconnect(self, after=5):
        if not self.auto_reconnect:
           return
        if SIPSessionApplication().stopping:
            return
        show_notice('Will reconnect to %s in %d seconds (Ctrl-D to stop)' % (self.target, after), bold=False)
        # A timer, not api.sleep: sleeping here blocks the green thread
        # that delivered the notification, and the reconnect then fires
        # regardless of what happened in the meantime -- including the
        # application shutting down.
        application = SIPSessionApplication()
        application.pending_reconnect = reactor.callLater(after, self._post_reconnect)

    def _post_reconnect(self):
        application = SIPSessionApplication()
        application.pending_reconnect = None
        if application.stopping:
            return
        notification_center = NotificationCenter()
        notification_center.post_notification('SessionMustReconnect', data=NotificationData(target=str(self.target)))

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def cancel(self):
        # Abort the call while it is still in DNS lookup. Needs a sipsimple
        # with DNSLookup.cancel(); older ones just let the lookup run out.
        self.cancelled = True
        cancel = getattr(getattr(self, 'lookup', None), 'cancel', None)
        if cancel is not None:
            cancel()
        else:
            show_notice('DNS lookup cannot be interrupted, the call will be dropped when it completes')

    def _NH_DNSLookupDidSucceed(self, notification):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=notification.sender)
        application = SIPSessionApplication()
        if application.pending_call is self:
            application.pending_call = None
        if getattr(self, 'cancelled', False) or application.stopping:
            show_notice('Call to %s cancelled' % self.target)
            self._playback_end(failed_reason='outgoing-cancelled')
            return
        session = Session(self.account)
        session._load = self.load
        notification_center.add_observer(self, sender=session)
        # Log the SRTP key negotiation policy that will drive the outgoing
        # offer (opportunistic / sdes_optional / sdes_mandatory / zrtp /
        # disabled). With the opportunistic-dual-offer change in the SDK,
        # 'opportunistic' now advertises both a=crypto and a=zrtp-hash.
        if self.account.rtp.encryption.enabled:
            key_negotiation = self.account.rtp.encryption.key_negotiation
        else:
            key_negotiation = 'disabled'
        show_notice('RTP encryption key_negotiation: %s' % key_negotiation)
        # X-Sylk-ZRTP capability header is added automatically by the
        # SDK (Session.connect) when account.rtp.encryption is
        # opportunistic or zrtp. The application no longer needs to
        # touch extra_headers for that.
        application = SIPSessionApplication()
        connect_kwargs = {}
        if application.radio_ptt is not None and _session_supports_contact_parameters():
            connect_kwargs['contact_parameters'] = {radio_ptt.FEATURE_TAG: None}
        session.connect(ToHeader(self.target), routes=notification.data.result,
                        streams=self.streams, **connect_kwargs)
        application.outgoing_session = session

    def _NH_DNSLookupDidFail(self, notification):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=notification.sender)
        application = SIPSessionApplication()
        if application.pending_call is self:
            application.pending_call = None
        if getattr(self, 'cancelled', False):
            show_notice('Call to %s cancelled' % self.target)
            self._playback_end(failed_reason='outgoing-cancelled')
            return
        show_notice('Call to %s failed: DNS lookup error: %s' % (self.target, notification.data.error))
        self._playback_end(failed_reason='outgoing-failed-DNS')
        self.reconnect(10)

    def _NH_SIPSessionNewOutgoing(self, notification):
        session = notification.sender
        local_identity = str(session.local_identity.uri)
        if session.local_identity.display_name:
            local_identity = '"%s" <%s>' % (session.local_identity.display_name, local_identity)
        remote_identity = str(session.remote_identity.uri)
        self.remote_identity = remote_identity.split(":")[1]
        if session.remote_identity.display_name:
            remote_identity = '"%s" <%s>' % (session.remote_identity.display_name, remote_identity)
        show_notice("Initiating SIP session from %s to %s via %s..." % (local_identity, remote_identity, session.route))
        self.message_session_to = None

    def _NH_SIPSessionGotRingIndication(self, notification):
        settings = SIPSimpleSettings()
        ui = UI()
        ringtone = settings.sounds.audio_outbound
        if ringtone and self.wave_ringtone is None and not self.play_file and not self.load:
            self.wave_ringtone = WavePlayer(SIPApplication.voice_audio_mixer, ringtone.path.normalized, volume=ringtone.volume, loop_count=0, pause_time=2)
            SIPApplication.voice_audio_bridge.add(self.wave_ringtone)
            self.wave_ringtone.start()
        ui.status = 'Ringing...'

    def _NH_SIPSessionWillStart(self, notification):
        ui = UI()
        if self.wave_ringtone:
            self.wave_ringtone.stop()
            SIPApplication.voice_audio_bridge.remove(self.wave_ringtone)
            self.wave_ringtone = None
        ui.status = 'Connecting...'

    def _NH_SIPSessionDidEnd(self, notification):
        self._playback_end()
        session = notification.sender
        show_notice('Session ended by %s' % notification.data.originator)
        identity = str(session.remote_identity.uri)

        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=session)

        if session.remote_identity.display_name:
            identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)

        if notification.data.originator == 'remote':
            self.reconnect(5)

    def _NH_SIPSessionDidStart(self, notification):
        notification_center = NotificationCenter()
        ui = UI()
        session = notification.sender
        ui.status = 'Connected'
        reactor.callLater(2, setattr, ui, 'status', None)

        application = SIPSessionApplication()

        for stream in notification.data.streams:
            if application.auto_record:
                settings = SIPSimpleSettings()
                if stream.type == 'audio':
                    direction = session.direction
                    remote = "%s@%s" % (session.remote_identity.uri.user, session.remote_identity.uri.host)
                    filename = "%s-%s-%s.wav" % (datetime.now().strftime("%Y%m%d-%H%M%S"), remote, direction)
                    path = os.path.join(settings.audio.directory.normalized, session.account.id, datetime.now().strftime("%Y%m%d"))
                    makedirs(path)
                    stream.start_recording(os.path.join(path, filename))
        
            if stream.type in ('audio', 'video'):
                show_notice('%s session established using %s codec at %sHz' % (stream.type.title(), stream.codec.capitalize(), stream.sample_rate))
                if stream.ice_active:
                    show_notice('%s RTP endpoints %s:%d (ICE type %s) <-> %s:%d (ICE type %s)' % (stream.type.title(), 
                                                                                                  stream.local_rtp_address,
                                                                                                  stream.local_rtp_port,
                                                                                                  stream.local_rtp_candidate.type.lower(),
                                                                                                  stream.remote_rtp_address,
                                                                                                  stream.remote_rtp_port,
                                                                                                  stream.remote_rtp_candidate.type.lower()
                                                                                                  )
                                                                                                  )
                else:
                    show_notice('%s RTP endpoints %s:%d <-> %s:%d' % (stream.type.title(), stream.local_rtp_address, stream.local_rtp_port, stream.remote_rtp_address, stream.remote_rtp_port))
                
        if session.remote_user_agent is not None:
            show_notice('Remote SIP User Agent is "%s"' % session.remote_user_agent)
            
    def _NH_MediaStreamDidStart(self, notification):
        stream = notification.sender
        if stream.type == 'audio' and self.play_file:
            script = "%s/scripts/%s-playback-start" % (self.playback_dir, self.remote_identity)
            if os.path.exists(script):
                show_notice("Running script %s" % script)
                p = subprocess.Popen(script)
            else:
                pass
                #show_notice("Script does not exist %s" % script)

            file = ResourcePath(self.play_file).normalized
            show_notice("Playback file %s\n" % file)

            self.playback_wave_player = WavePlayer(SIPApplication.voice_audio_mixer, file)
            notification_center = NotificationCenter()
            notification_center.add_observer(self, sender=self.playback_wave_player)
            stream.bridge.add(self.playback_wave_player)
            #SIPApplication.voice_audio_bridge.add(self.playback_wave_player)
            self.playback_wave_player.start()

    def _NH_WavePlayerDidEnd(self, notification):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=notification.sender)
        show_notice("Playback finished")

        if notification.sender == self.playback_wave_player:
            application = SIPSessionApplication()
            if application.outgoing_session:
                application.outgoing_session.end()

    def _NH_WavePlayerDidFail(self, notification):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=notification.sender)
        show_notice('Playback %s failed: %s' % (notification.sender.filename, notification.data.error))
        if notification.sender == self.playback_wave_player or self.play_file:

            application = SIPSessionApplication()
            if application.outgoing_session:
                application.outgoing_session.end()

            self._playback_end(failed_reason='outgoing-failed-playback')
                                    
    def _NH_SIPSessionDidFail(self, notification):
        code = notification.data.code
        self._playback_end(failed_reason='outgoing-failed-' + str(code))

        notification_center = NotificationCenter()
        session = notification.sender
        notification_center.remove_observer(self, sender=session)
        if self.playback_wave_player:
            SIPApplication.voice_audio_bridge.remove(self.playback_wave_player)

        ui = UI()
        ui.status = None

        application = SIPSessionApplication()
        application.outgoing_session = None

        if self.wave_ringtone:
            self.wave_ringtone.stop()
            SIPApplication.voice_audio_bridge.remove(self.wave_ringtone)
            self.wave_ringtone = None
        if notification.data.failure_reason == 'user request' and code == 487:
            show_notice('SIP session cancelled')
        elif notification.data.failure_reason == 'user request':
            show_notice('SIP session rejected by user (%d %s)' % (code, notification.data.reason))
        else:
            show_notice('SIP session failed: %s (%s %s)' % (notification.data.failure_reason, code, notification.data.reason))

        if notification.data.failure_reason != 'user request':
            self.reconnect(15)

    def _remove_lock(self):
        # used by external audio recorder to know if we are in call
        lock_file = "%s/playback.lock" % self.playback_dir
        if os.path.exists(lock_file):
            os.remove(lock_file)

    def _playback_end(self, failed_reason=None):
        reactor.callLater(0.1, self._remove_lock)

        if not self.play_file:
            return

        settings = SIPSimpleSettings()
        application = SIPSessionApplication()

        if failed_reason or not application.auto_record:
            save_path = os.path.join(settings.audio.directory.normalized, self.account.id, datetime.now().strftime("%Y%m%d"))
            makedirs(save_path)
            base = Path(self.play_file).stem
            ext = Path(self.play_file).suffix
            save_path = save_path + '/' + base + '-' + (failed_reason or 'outgoing') + ext
            show_notice("Saved playback file to %s\n" % save_path)
            os.rename(self.play_file, save_path)

        if not failed_reason:
            settings = SIPSimpleSettings()
            api.sleep(0.2)
            rogertone = settings.sounds.roger_beep
            roger_tone = WavePlayer(SIPApplication.voice_audio_mixer, rogertone.path.normalized)
            show_notice("Play roger beep")
            SIPApplication.voice_audio_bridge.add(roger_tone)
            roger_tone.start()
        else:     
            api.sleep(0.2)
            hangup_tone = WavePlayer(SIPApplication.voice_audio_mixer, ResourcePath('sounds/hangup_tone.wav').normalized)
            SIPApplication.voice_audio_bridge.add(hangup_tone)
            hangup_tone.start()
            
        script = "%s/scripts/%s-playback-end" % (self.playback_dir, self.remote_identity)
        if os.path.exists(script):
            show_notice("Running script %s\n" % script)
            p = subprocess.Popen(script)

        if os.path.exists(self.play_file):
            try:
                os.remove(self.play_file)
            except OSError:
                pass

        self.play_file = None
         
@implementer(IObserver)
class IncomingCallInitializer(object):

    sessions = 0
    tone_ringtone = None

    def __init__(self, session, auto_answer_interval=None):
        self.session = session
        self.auto_answer_interval = auto_answer_interval
        self.question = None

    def start(self):
        IncomingCallInitializer.sessions += 1
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=self.session)

        # start auto-answer
        self.answer_timer = None
        if self.auto_answer_interval == 0:
            self.session.accept(self.session.proposed_streams)
            return
        elif self.auto_answer_interval and self.auto_answer_interval > 0:
            self.answer_timer = reactor.callFromThread(reactor.callLater, self.auto_answer_interval, self.session.accept, self.session.proposed_streams)

        # start ringing
        application = SIPSessionApplication()
        self.wave_ringtone = None
        if application.active_session is None:
            if IncomingCallInitializer.sessions == 1:
                ringtone = self.session.account.sounds.audio_inbound.sound_file if self.session.account.sounds.audio_inbound is not None else None
                if ringtone:
                    self.wave_ringtone = WavePlayer(SIPApplication.alert_audio_mixer, ringtone.path.normalized, volume=ringtone.volume, loop_count=0, pause_time=2)
                    SIPApplication.alert_audio_bridge.add(self.wave_ringtone)
                    self.wave_ringtone.start()
        elif IncomingCallInitializer.tone_ringtone is None:
            IncomingCallInitializer.tone_ringtone = WavePlayer(SIPApplication.voice_audio_mixer, ResourcePath('sounds/ring_tone.wav').normalized, loop_count=0, pause_time=6)
            SIPApplication.voice_audio_bridge.add(IncomingCallInitializer.tone_ringtone)
            IncomingCallInitializer.tone_ringtone.start()
        self.session.send_ring_indication()

        # ask question
        identity = str(self.session.remote_identity.uri)
        display_name = self.session.remote_identity.display_name
        if display_name:
            uri = self.session.remote_identity.uri
            user = uri.user.decode() if isinstance(uri.user, bytes) else (uri.user or '')
            host = uri.host.decode() if isinstance(uri.host, bytes) else (uri.host or '')
            bare = '%s@%s' % (user, host)
            if display_name not in (identity, bare):
                identity = '"%s" <%s>' % (display_name, identity)
        streams = '/'.join(stream.type for stream in self.session.proposed_streams)
        if self.session.replaced_session:
            self.session.accept(self.session.proposed_streams)
            show_notice("SIP session was transfered to %s" % identity)
        else:
            self.question = Question("Incoming %s from %s, do you want to accept? (a)ccept/(r)eject/(b)usy" % (streams, identity), 'arbi', bold=True)
            notification_center.add_observer(self, sender=self.question)
            ui = UI()
            ui.add_question(self.question)

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_UIQuestionGotAnswer(self, notification):
        notification_center = NotificationCenter()
        ui = UI()
        application = SIPSessionApplication()
        notification_center.remove_observer(self, sender=notification.sender)
        answer = notification.data.answer
        self.question = None
        #show_notice('Got answer %s' % answer)
        if answer == 'a':
            if application.received_private_key:
                self.question = Question("Please enter the pincode to decrypt the key...", 'ar', bold=True)
                ui.add_question(self.question)
                return
            self.session.accept(self.session.proposed_streams)
            ui.status = 'Accepting...'
        elif answer == 'r':
            if application.received_private_key:
                application.received_private_key = None
                return
            self.session.reject()
            ui.status = 'Rejected'
            reactor.callLater(3, setattr, ui, 'status', None)
        elif answer == 'b':
            self.session.reject(486)
            ui.status = 'Sent Busy Here'
            reactor.callLater(3, setattr, ui, 'status', None)

        if self.wave_ringtone:
            self.wave_ringtone.stop()
            self.wave_ringtone = None
        if IncomingCallInitializer.sessions > 1:
            if IncomingCallInitializer.tone_ringtone is None:
                IncomingCallInitializer.tone_ringtone = WavePlayer(SIPApplication.voice_audio_mixer, ResourcePath('sounds/ring_tone.wav').normalized, loop_count=0, pause_time=6)
                SIPApplication.voice_audio_bridge.add(IncomingCallInitializer.tone_ringtone)
                IncomingCallInitializer.tone_ringtone.start()
        elif IncomingCallInitializer.tone_ringtone:
            IncomingCallInitializer.tone_ringtone.stop()
            IncomingCallInitializer.tone_ringtone = None
        if self.answer_timer is not None and self.answer_timer.active():
            self.answer_timer.cancel()

    def _NH_SIPSessionWillStart(self, notification):
        ui = UI()
        if self.question is not None:
            notification_center = NotificationCenter()
            notification_center.remove_observer(self, sender=self.question)
            ui.remove_question(self.question)
            self.question = None
        ui.status = 'Connecting...'

    def _NH_SIPSessionDidStart(self, notification):
        notification_center = NotificationCenter()
        session = notification.sender
        notification_center.remove_observer(self, sender=session)
        IncomingCallInitializer.sessions -= 1

        ui = UI()
        ui.status = 'Connected'
        reactor.callLater(2, setattr, ui, 'status', None)

        identity = str(session.remote_identity.uri)
        if session.remote_identity.display_name:
            identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
        show_notice("SIP session with %s established" % identity)

        for stream in notification.data.streams:
            if stream.type in ('audio', 'video'):
                show_notice('%s stream using %s codec at %sHz' % (stream.type.title(), stream.codec.capitalize(), stream.sample_rate))

                if stream.ice_active:
                    show_notice('%s RTP endpoints %s:%d (ICE type %s) <-> %s:%d (ICE type %s)' % (stream.type.title(), stream.local_rtp_address,
                                                                                                      stream.local_rtp_port,
                                                                                                      stream.local_rtp_candidate.type.lower(),
                                                                                                      stream.remote_rtp_address,
                                                                                                      stream.remote_rtp_port,
                                                                                                      stream.remote_rtp_candidate.type.lower()))
                else:
                    show_notice('%s RTP endpoints %s:%d <-> %s:%d' % (stream.type.title(),
                                                                             stream.local_rtp_address,
                                                                             stream.local_rtp_port,
                                                                             stream.remote_rtp_address,
                                                                             stream.remote_rtp_port))
                if stream.encryption.active:
                    show_notice('%s RTP stream is encrypted using %s (%s)' % (stream.type.title(), stream.encryption.type, (lambda c: c.decode() if isinstance(c, bytes) else c)(stream.encryption.cipher)))
                if stream.type == 'video':
                    settings = SIPSimpleSettings()
                    camera = settings.video.device if settings.video.device is not None else 'system default'
                    resolution = settings.video.resolution
                    framerate = settings.video.framerate
                    max_bitrate = settings.video.max_bitrate
                    show_notice('Video camera: %s (configured %sx%s @ %s fps, max bitrate %s kbps)' % (
                        camera,
                        resolution.width, resolution.height,
                        framerate,
                        int(max_bitrate * 1000) if max_bitrate else 'unlimited'))
        if session.remote_user_agent is not None:
            show_notice('Remote SIP User Agent is "%s"' % session.remote_user_agent)

    def _NH_SIPSessionDidFail(self, notification):
        notification_center = NotificationCenter()
        ui = UI()
        session = notification.sender
        notification_center.remove_observer(self, sender=session)

        ui.status = None

        if self.question is not None:
            notification_center.remove_observer(self, sender=self.question)
            ui.remove_question(self.question)
            self.question = None

        IncomingCallInitializer.sessions -= 1
        if self.wave_ringtone:
            self.wave_ringtone.stop()
            self.wave_ringtone = None
        if IncomingCallInitializer.sessions == 0 and IncomingCallInitializer.tone_ringtone is not None:
            IncomingCallInitializer.tone_ringtone.stop()
            IncomingCallInitializer.tone_ringtone = None
        if notification.data.failure_reason == 'user request' and notification.data.code == 487:
            show_notice('SIP session cancelled by user')
        if notification.data.failure_reason == 'Call completed elsewhere' and notification.data.code == 487:
            show_notice('SIP session cancelled, call was answered elsewhere')
        elif notification.data.failure_reason == 'user request':
            show_notice('SIP session rejected (%d %s)' % (notification.data.code, notification.data.reason))
        else:
            show_notice('SIP session failed: %s' % notification.data.failure_reason)


@implementer(IObserver)
class OutgoingProposalHandler(object):

    def __init__(self, session, audio=False, chat=False, video=False):
        self.session = session
        self.stream = None
        if audio:
            self.stream = MediaStreamRegistry.AudioStream()
        if chat:
            self.stream = MediaStreamRegistry.ChatStream()
        if video:
            self.stream = MediaStreamRegistry.VideoStream()
        if not self.stream:
            raise ValueError("Need to specify exactly one stream")

    def start(self):
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=self.session)
        try:
            self.session.add_stream(self.stream)
        except IllegalStateError:
            notification_center.remove_observer(self, sender=self.session)
            raise

        remote_identity = str(self.session.remote_identity.uri)
        if self.session.remote_identity.display_name:
            remote_identity = '"%s" <%s>' % (self.session.remote_identity.display_name, remote_identity)
        show_notice("Proposing %s to '%s'..." % (self.stream.type, remote_identity))

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPSessionProposalAccepted(self, notification):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=self.session)
        application = SIPSessionApplication()
        application.sessions_with_proposals.remove(notification.sender)
        show_notice('Proposal accepted')

    def _NH_SIPSessionProposalRejected(self, notification):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=self.session)
        application = SIPSessionApplication()
        application.sessions_with_proposals.remove(notification.sender)

        ui = UI()
        ui.status = None
        if notification.data.code == 487:
            show_notice('Proposal cancelled (%d %s)' % (notification.data.code, notification.data.reason))
        else:
            show_notice('Proposal rejected (%d %s)' % (notification.data.code, notification.data.reason))

    def _NH_SIPSessionDidEnd(self, notification):
        notification_center = NotificationCenter()
        notification_center.discard_observer(self, sender=self.session)


@implementer(IObserver)
class IncomingProposalHandler(object):

    sessions = 0
    tone_ringtone = None

    def __init__(self, session):
        self.session = session
        self.question = None

    def start(self):
        IncomingProposalHandler.sessions += 1
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=self.session)

        # start ringing
        if IncomingProposalHandler.tone_ringtone is None:
            IncomingProposalHandler.tone_ringtone = WavePlayer(SIPApplication.voice_audio_mixer, ResourcePath('sounds/ring_tone.wav').normalized, loop_count=0, pause_time=6)
            SIPApplication.voice_audio_bridge.add(IncomingProposalHandler.tone_ringtone)
            IncomingProposalHandler.tone_ringtone.start()
        self.session.send_ring_indication()

        # ask question
        identity = str(self.session.remote_identity.uri)
        if self.session.remote_identity.display_name:
            identity = '"%s" <%s>' % (self.session.remote_identity.display_name, identity)
        streams = ', '.join(stream.type for stream in self.session.proposed_streams)
        self.question = Question("'%s' wants to add %s, do you want to accept? (a)ccept/(r)eject" % (identity, streams), 'ar', bold=True)
        notification_center.add_observer(self, sender=self.question)
        ui = UI()
        ui.add_question(self.question)

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_UIQuestionGotAnswer(self, notification):
        notification_center = NotificationCenter()
        ui = UI()
        notification_center.remove_observer(self, sender=notification.sender)
        answer = notification.data.answer
        self.question = None
        if answer == 'a':
            self.session.accept_proposal(self.session.proposed_streams)
            ui.status = 'Accepting proposal...'
        elif answer == 'r':
            self.session.reject_proposal()
            ui.status = 'Rejecting proposal...'

        if IncomingProposalHandler.sessions == 1 and IncomingProposalHandler.tone_ringtone:
            IncomingProposalHandler.tone_ringtone.stop()
            IncomingProposalHandler.tone_ringtone = None

    def _NH_SIPSessionProposalAccepted(self, notification):
        notification_center = NotificationCenter()
        session = notification.sender
        notification_center.remove_observer(self, sender=session)
        application = SIPSessionApplication()
        application.sessions_with_proposals.remove(notification.sender)
        IncomingProposalHandler.sessions -= 1

        ui = UI()
        ui.status = None
        show_notice('Proposal accepted')

    def _NH_SIPSessionProposalRejected(self, notification):
        notification_center = NotificationCenter()
        session = notification.sender
        notification_center.remove_observer(self, sender=session)
        application = SIPSessionApplication()
        application.sessions_with_proposals.remove(notification.sender)
        IncomingProposalHandler.sessions -= 1

        ui = UI()
        ui.status = None
        if notification.data.code == 487:
            show_notice('Proposal cancelled (%d %s)' % (notification.data.code, notification.data.reason))
        else:
            show_notice('Proposal rejected (%d %s)' % (notification.data.code, notification.data.reason))

        if IncomingProposalHandler.tone_ringtone:
            IncomingProposalHandler.tone_ringtone.stop()
            IncomingProposalHandler.tone_ringtone = None

        if self.question is not None:
            notification_center.remove_observer(self, sender=self.question)
            ui.remove_question(self.question)
            self.question = None

    def _NH_SIPSessionHadProposalFailure(self, notification):
        notification_center = NotificationCenter()
        session = notification.sender
        notification_center.remove_observer(self, sender=session)
        IncomingProposalHandler.sessions -= 1

        ui = UI()
        ui.status = None
        show_notice('Proposal failed (%s)' % notification.data.failure_reason)

    def _NH_SIPSessionDidEnd(self, notification):
        notification_center = NotificationCenter()
        ui = UI()
        session = notification.sender
        notification_center.discard_observer(self, sender=session)

        ui.status = None

        if self.question is not None:
            notification_center.remove_observer(self, sender=self.question)
            ui.remove_question(self.question)
            self.question = None

        IncomingProposalHandler.sessions -= 1
        if IncomingProposalHandler.sessions == 0 and IncomingProposalHandler.tone_ringtone is not None:
            IncomingProposalHandler.tone_ringtone.stop()
            IncomingProposalHandler.tone_ringtone = None


@implementer(IObserver)
class OutgoingTransferHandler(object):

    def __init__(self, account, target, filepath):
        self.account = account
        self.target = target
        self.filepath = filepath
        self.file_selector = None
        self.hash_compute_proc = None
        self.session = None
        self.stream = None
        self.handler = None
        self.wave_ringtone = None

    @run_in_green_thread
    def start(self):
        if isinstance(self.account, BonjourAccount) and '@' not in self.target:
            show_notice('Bonjour mode requires a host in the destination address')
            return
        if '@' not in self.target:
            self.target = '%s@%s' % (self.target, self.account.id.domain)
        if not self.target.startswith('sip:') and not self.target.startswith('sips:'):
            self.target = 'sip:' + self.target
        try:
            self.target = SIPURI.parse(self.target)
        except SIPCoreError:
            show_notice('Illegal SIP URI: %s' % self.target)
            return

        show_notice('Preparing transfer...')
        self.file_selector = FileSelector.for_file(self.filepath)
        if '.' not in self.target.host.decode() and not isinstance(self.account, BonjourAccount):
            self.target.host = ('%s.%s' % (self.target.host.decode(), self.account.id.domain)).encode()
        lookup = DNSLookup()
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=lookup)
        settings = SIPSimpleSettings()

        if isinstance(self.account, Account) and self.account.sip.outbound_proxy is not None:
            uri = SIPURI(host=self.account.sip.outbound_proxy.host, port=self.account.sip.outbound_proxy.port, parameters={'transport': self.account.sip.outbound_proxy.transport})
            tls_name = self.account.sip.tls_name or self.account.sip.outbound_proxy.host
        elif isinstance(self.account, Account) and self.account.sip.always_use_my_proxy:
            uri = SIPURI(host=self.account.id.domain)
            tls_name = self.account.sip.tls_name or self.account.id.domain
        else:
            uri = self.target
            tls_name = uri.host
            if self.account is not BonjourAccount():
                if self.account.id.domain == uri.host.decode():
                    tls_name = self.account.sip.tls_name or self.account.id.domain
                elif "isfocus" in str(uri) and uri.host.decode().endswith(self.account.id.domain):
                    tls_name = self.account.conference.tls_name or self.account.sip.tls_name or self.account.id.domain
            else:
                is_ip_address = re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", uri.host.decode()) or ":" in uri.host.decode()
                if "isfocus" in str(uri) and self.account.conference.tls_name:
                    tls_name = self.account.conference.tls_name
                elif  is_ip_address and  self.account.sip.tls_name:
                    tls_name = self.account.sip.tls_name

        lookup.lookup_sip_proxy(uri, settings.sip.transport_list, tls_name=tls_name)

    def _terminate(self, failure_reason=None):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=self.session)
        notification_center.remove_observer(self, sender=self.stream)
        notification_center.remove_observer(self, sender=self.handler)

        ui = UI()
        ui.status = None

        if self.wave_ringtone:
            self.wave_ringtone.stop()
            self.wave_ringtone = None

        if failure_reason is None:
            show_notice('File transfer of %s succeeded' % self.filepath)
        else:
            show_notice('File transfer of %s failed: %s' % (self.filepath, failure_reason))

        self.session = None
        self.stream = None
        self.handler = None

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_DNSLookupDidSucceed(self, notification):
        notification.center.remove_observer(self, sender=notification.sender)
        self.session = Session(self.account)
        self.stream = MediaStreamRegistry.FileTransferStream(self.file_selector, 'sendonly')
        self.handler = self.stream.handler
        notification.center.add_observer(self, sender=self.session)
        notification.center.add_observer(self, sender=self.stream)
        notification.center.add_observer(self, sender=self.handler)
        routes = notification.data.result
        self.session.connect(ToHeader(self.target), routes=routes, streams=[self.stream])

    def _NH_DNSLookupDidFail(self, notification):
        notification.center.remove_observer(self, sender=notification.sender)
        show_notice('File transfer to %s failed: DNS lookup error: %s' % (self.target, notification.data.error))

    def _NH_SIPSessionNewOutgoing(self, notification):
        session = notification.sender
        local_identity = str(session.local_identity.uri)
        if session.local_identity.display_name:
            local_identity = '"%s" <%s>' % (session.local_identity.display_name, local_identity)
        remote_identity = str(session.remote_identity.uri)
        if session.remote_identity.display_name:
            remote_identity = '"%s" <%s>' % (session.remote_identity.display_name, remote_identity)
        show_notice("Initiating file transfer from '%s' to '%s' via %s..." % (local_identity, remote_identity, session.route))

    def _NH_SIPSessionGotRingIndication(self, notification):
        settings = SIPSimpleSettings()
        ui = UI()
        ringtone = settings.sounds.audio_outbound
        if ringtone and self.wave_ringtone is None:
            self.wave_ringtone = WavePlayer(SIPApplication.voice_audio_mixer, ringtone.path.normalized, volume=ringtone.volume, loop_count=0, pause_time=2)
            SIPApplication.voice_audio_bridge.add(self.wave_ringtone)
            self.wave_ringtone.start()
        ui.status = 'Ringing...'

    def _NH_SIPSessionWillStart(self, notification):
        ui = UI()
        if self.wave_ringtone:
            self.wave_ringtone.stop()
        ui.status = 'Connecting...'

    def _NH_SIPSessionDidStart(self, notification):
        session = notification.sender

        ui = UI()
        ui.status = 'File transfer connected'

        identity = str(session.remote_identity.uri)
        if session.remote_identity.display_name:
            identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
        show_notice("File transfer for %s to '%s' started" % (os.path.basename(self.filepath), identity))

    def _NH_FileTransferHandlerHashProgress(self, notification):
        progress = int(notification.data.processed*100/notification.data.total)
        if progress % 10 == 0:
            ui = UI()
            if progress < 100:
                ui.status = 'Calculating checksum for %s: %s%%' % (self.filepath, progress)
            else:
                ui.status = 'Sending file transfer request...'

    def _NH_MediaStreamDidNotInitialize(self, notification):
        self._terminate(failure_reason=notification.data.reason)

    def _NH_FileTransferHandlerDidEnd(self, notification):
        self.session.end()
        self._terminate(failure_reason=notification.data.reason)


@implementer(IObserver)
class IncomingTransferHandler(object):

    sessions = 0
    tone_ringtone = None

    def __init__(self, session, auto_answer_interval=None):
        self.session = session
        self.stream = None
        self.handler = None
        self.auto_answer_interval = auto_answer_interval
        self.file = None
        self.filename = None
        self.finished = False
        self.hash = None
        self.question = None
        self.wave_ringtone = None

    def start(self):
        self.stream = self.session.proposed_streams[0]
        self.handler = self.stream.handler
        self.file_selector = self.stream.file_selector
        self.filename = self.file_selector.name

        IncomingTransferHandler.sessions += 1
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=self.session)
        notification_center.add_observer(self, sender=self.stream)
        notification_center.add_observer(self, sender=self.handler)

        # start auto-answer
        self.answer_timer = None
        if self.auto_answer_interval == 0:
            self.session.accept(self.session.proposed_streams)
            return
        elif (self.auto_answer_interval and self.auto_answer_interval > 0):
            self.answer_timer = reactor.callFromThread(reactor.callLater, self.auto_answer_interval, self.session.accept, self.session.proposed_streams)

        # start ringing
        application = SIPSessionApplication()
        if application.active_session is None:
            if IncomingTransferHandler.sessions == 1:
                ringtone = self.session.account.sounds.audio_inbound.sound_file if self.session.account.sounds.audio_inbound is not None else None
                if ringtone:
                    self.wave_ringtone = WavePlayer(SIPApplication.alert_audio_mixer, ringtone.path.normalized, volume=ringtone.volume, loop_count=0, pause_time=2)
                    SIPApplication.alert_audio_bridge.add(self.wave_ringtone)
                    self.wave_ringtone.start()
        elif IncomingTransferHandler.tone_ringtone is None:
            IncomingTransferHandler.tone_ringtone = WavePlayer(SIPApplication.voice_audio_mixer, ResourcePath('sounds/ring_tone.wav').normalized, loop_count=0, pause_time=6)
            SIPApplication.voice_audio_bridge.add(IncomingTransferHandler.tone_ringtone)
            IncomingTransferHandler.tone_ringtone.start()
        self.session.send_ring_indication()

        # ask question
        identity = str(self.session.remote_identity.uri)
        display_name = self.session.remote_identity.display_name
        if display_name:
            uri = self.session.remote_identity.uri
            user = uri.user.decode() if isinstance(uri.user, bytes) else (uri.user or '')
            host = uri.host.decode() if isinstance(uri.host, bytes) else (uri.host or '')
            bare = '%s@%s' % (user, host)
            if display_name not in (identity, bare):
                identity = '"%s" <%s>' % (display_name, identity)
        self.question = Question("Incoming file transfer for %s from '%s', do you want to accept? (a)ccept/(r)eject" % (os.path.basename(self.filename), identity), 'ari', bold=True)
        notification_center.add_observer(self, sender=self.question)
        ui = UI()
        ui.add_question(self.question)

    def _terminate(self, failure_reason=None):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=self.session)
        notification_center.remove_observer(self, sender=self.stream)
        notification_center.remove_observer(self, sender=self.handler)

        ui = UI()
        ui.status = None

        if self.question is not None:
            notification_center.remove_observer(self, sender=self.question)
            ui.remove_question(self.question)
            self.question = None

        if self.wave_ringtone:
            self.wave_ringtone.stop()

        if failure_reason is None:
            show_notice('File saved to %s' % self.filename)
        else:
            show_notice('File transfer of %s failed: %s' % (self.filename, failure_reason))

        self.session = None
        self.stream = None
        self.handler = None

    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_UIQuestionGotAnswer(self, notification):
        notification_center = NotificationCenter()
        ui = UI()
        notification_center.remove_observer(self, sender=notification.sender)
        answer = notification.data.answer
        self.question = None
        if answer == 'a':
            self.session.accept(self.session.proposed_streams)
            ui.status = 'Accepting...'
        elif answer == 'r':
            self.session.reject()
            ui.status = 'Rejected'
            reactor.callLater(3, setattr, ui, 'status', None)

        if IncomingTransferHandler.sessions == 1:
            if self.wave_ringtone:
                self.wave_ringtone.stop()
                self.wave_ringtone = None
            if IncomingTransferHandler.tone_ringtone:
                IncomingTransferHandler.tone_ringtone.stop()
                IncomingTransferHandler.tone_ringtone = None
        if self.answer_timer is not None and self.answer_timer.active():
            self.answer_timer.cancel()

    def _NH_SIPSessionWillStart(self, notification):
        ui = UI()
        if self.question is not None:
            notification_center = NotificationCenter()
            notification_center.remove_observer(self, sender=self.question)
            ui.remove_question(self.question)
            self.question = None
        ui.status = 'Connecting...'

        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=notification.sender.proposed_streams[0])

    def _NH_SIPSessionDidStart(self, notification):
        session = notification.sender
        IncomingCallInitializer.sessions -= 1

        ui = UI()
        ui.status = 'File transfer connected'

        identity = str(session.remote_identity.uri)
        if session.remote_identity.display_name:
            identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
        show_notice("File transfer for %s with %s started" % (os.path.basename(self.filename), identity))

        if IncomingTransferHandler.sessions == 1:
            if self.wave_ringtone:
                self.wave_ringtone.stop()
                self.wave_ringtone = None
            if IncomingTransferHandler.tone_ringtone:
                IncomingTransferHandler.tone_ringtone.stop()
                IncomingTransferHandler.tone_ringtone = None

    def _NH_SIPSessionDidFail(self, notification):
        IncomingTransferHandler.sessions -= 1
        if self.wave_ringtone:
            self.wave_ringtone.stop()
            self.wave_ringtone = None
        if IncomingTransferHandler.sessions == 0 and IncomingTransferHandler.tone_ringtone is not None:
            IncomingTransferHandler.tone_ringtone.stop()
            IncomingTransferHandler.tone_ringtone = None

    def _NH_MediaStreamDidNotInitialize(self, notification):
        self._terminate(failure_reason=notification.data.reason)

    def _NH_FileTransferHandlerDidInitialize(self, notification):
        self.filename = self.stream.file_selector.name

    def _NH_FileTransferHandlerProgress(self, notification):
        ui = UI()
        ui.status = '%s: %s%%' % (os.path.basename(self.filename), notification.data.transferred_bytes*100//notification.data.total_bytes)

    def _NH_FileTransferHandlerDidEnd(self, notification):
        reactor.callFromThread(reactor.callLater, 0, self.session.end)
        self._terminate(failure_reason=notification.data.reason)


class SIPSessionApplication(SIPApplication):
    # public methods
    #

    # Is-composing. The refresh is what we promise the remote party: it may
    # keep the indicator up for this long without hearing from us again, so
    # a keystroke only costs a MESSAGE once every half of it. The idle
    # timeout is how long a line can sit untouched before we say we stopped
    # -- short enough that walking away clears the indicator quickly, long
    # enough to survive thinking about the next word.
    COMPOSING_REFRESH = 60
    COMPOSING_REFRESH_INTERVAL = 30
    COMPOSING_IDLE_TIMEOUT = 10

    def __init__(self):
        self.account = None
        self.options = None
        self.target = None
        self.filepath = None
        self.enable_video = False
        
        self.active_session = None
        self.message_session_to = None
        self.outgoing_session = None
        self.pending_call = None  # OutgoingCallInitializer still in DNS lookup
        self.invite_refused = set()  # invitees who declined to join: not invited again
        self.connected_sessions = []
        self.sessions_with_proposals = set()
        self.hangup_timers = {}
        self.neighbours = {}
        self.registration_succeeded = {}
        self.stopped_event = Event()
        self.received_message_ids = set()
        
        self.message_sessions = set()
        # The message session we have told that we are typing, when we last
        # said so, and the timer that will say we stopped.
        self.composing_session = None
        self.composing_refreshed = None
        self.composing_timer = None
        self.received_private_key = None
        self.private_keys = {}
        self.public_keys = {}
        self.question = None
        self.pending_key_generate_account = None

        self.ip_address_monitor = IPAddressMonitor()
        self.logger = None
        self.rtp_statistics = None

        self.hold_tone = None

        self.ignore_local_hold = False
        self.ignore_local_unhold = False

        self.smp_verification_question = 'What is the ZRTP authentication string?'
        self.smp_verifified_using_zrtp = False
        self.smp_verification_delay = 0
        self.smp_verification_tries = 5
        self.smp_secret = None
        self.must_exit = False
        self.stopping = False
        self.pending_reconnect = None
        self.last_failure_reason = None
        self.enable_playback = False
        self.auto_record = False
        # session -> [(VideoWindow, FrameBufferVideoRenderer), ...]
        # one entry per active video stream on the session.  Attached
        # in _NH_SIPSessionDidStart and torn down in _NH_SIPSessionDidEnd.
        self.video_windows = {}
        # session -> subprocess.Popen of the per-call tcpdump process.
        # Started in _NH_SIPSessionDidStart when --dump is given on
        # the command line, terminated in _NH_SIPSessionDidEnd.
        self.pcap_processes = {}
        # Radio PTT endpoint controller (sipclient.radio_ptt), created at
        # application start when settings.radio_ptt.on_command is set.
        self.radio_ptt = None

    def handle_notification(self, notification):
        alive_file = os.path.join(config_directory, 'last_notification')
        Path(alive_file).touch()

        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def start(self, target, options, filepath=None):
        notification_center = NotificationCenter()

        ui = UI()

        history_file = os.path.join(config_directory, 'input.history')
        self.keys_path = os.path.join(config_directory, 'keys')
        makedirs(self.keys_path)
        
        self.enable_playback = options.enable_playback
        if options.playback_dir:
            self.playback_dir = options.playback_dir
        else:
            self.playback_dir = "%s/spool/playback" % config_directory
            makedirs(self.playback_dir)

        lock_file = "%s/playback.lock" % self.playback_dir
        if os.path.exists(lock_file):
            os.remove(lock_file)

        if self.enable_playback:
            self.playback_queue = EventQueue(self._handle_outgoing_playback)

            active_playback_dir = self.playback_dir + '/active'
            makedirs(active_playback_dir)

            scripts_playback_dir = self.playback_dir + '/scripts'
            makedirs(scripts_playback_dir)

        self.auto_record = options.auto_record

        ui.input.add_history(history_file)

        self.options = options
        self.target = target
        self.filepath = filepath
        self.logger = Logger(sip_to_stdout=options.trace_sip,
                             msrp_to_stdout=options.trace_msrp,
                             pjsip_to_stdout=options.trace_pjsip,
                             notifications_to_stdout=options.trace_notifications)

        notification_center.add_observer(self, sender=self)
        notification_center.add_observer(self, sender=ui)
        notification_center.add_observer(self, name='SIPSessionNewIncoming')
        notification_center.add_observer(self, name='SIPSessionNewIncomingFromTransferFailed')
        notification_center.add_observer(self, name='SIPSessionNewOutgoing')
        notification_center.add_observer(self, name='RTPStreamDidChangeRTPParameters')
        notification_center.add_observer(self, name='RTPStreamICENegotiationDidSucceed')
        notification_center.add_observer(self, name='RTPStreamICENegotiationDidFail')
        notification_center.add_observer(self, name='SIPEngineGotMessage')
        notification_center.add_observer(self, name='SIPSessionDidEnd')
        notification_center.add_observer(self, name='SIPSessionDidFail')
        notification_center.add_observer(self, name='MediaStreamDidNotInitialize')
        notification_center.add_observer(self, name='SIPSessionGotConferenceInfo')
        notification_center.add_observer(self, name='SIPSessionTransferGotProgress')
        notification_center.add_observer(self, name='SIPSessionTransferDidFail')
        notification_center.add_observer(self, name='SIPSessionTransferNewIncoming')
        notification_center.add_observer(self, name='RTPStreamICENegotiationStateDidChange')
        notification_center.add_observer(self, name='RTPStreamICENegotiationDidFail')
        notification_center.add_observer(self, name='RTPStreamICENegotiationDidSucceed')
        notification_center.add_observer(self, name='ChatStreamOTREncryptionStateChanged')
        notification_center.add_observer(self, name='VideoStreamRemoteFormatDidChange')
        notification_center.add_observer(self, name='TLSTransportHasChanged')
        notification_center.add_observer(self, name='MediaStreamDidFail')
        notification_center.add_observer(self, name='AudioDevicesDidChange')
        notification_center.add_observer(self, name='DefaultAudioDeviceDidChange')
        notification_center.add_observer(self, name='SessionMustReconnect')
        notification_center.add_observer(self, name='SIPIncomingReferralGotRefer')
        notification_center.add_observer(self, name='SIPConferenceGotAddParticipantProgress')
        notification_center.add_observer(self, name='SIPConferenceDidAddParticipant')
        notification_center.add_observer(self, name='SIPConferenceDidNotAddParticipant')
        notification_center.add_observer(self, name='SIPReferralGotNotify')

        log.level.current = log.level.WARNING # get rid of twisted messages
        control_bindings={'s': 'trace sip',
                          'm': 'trace msrp',
                          'j': 'trace pjsip',
                          'n': 'trace notifications',
                          'h': 'hangup',
                          'r': 'record',
                          'c': 'camera',
                          'i': 'input',
                          'o': 'output',
                          'a': 'alert',
                          'u': 'mute',
                          ' ': 'hold',
                          'q': 'quit',
                          '/': 'help',
                          '?': 'help',
                          '0': 'dtmf 0',
                          '1': 'dtmf 1',
                          '2': 'dtmf 2',
                          '3': 'dtmf 3',
                          '4': 'dtmf 4',
                          '5': 'dtmf 5',
                          '6': 'dtmf 6',
                          '7': 'dtmf 7',
                          '8': 'dtmf 8',
                          '9': 'dtmf 9',
                          '*': 'dtmf *',
                          '#': 'dtmf #',
                          'A': 'dtmf A',
                          'B': 'dtmf B',
                          'C': 'dtmf C',
                          'D': 'dtmf D'}

        ui.start(control_bindings=control_bindings, display_text=False)

        Account.register_extension(AccountExtension)
        BonjourAccount.register_extension(BonjourAccountExtension)
        SIPSimpleSettings.register_extension(SIPSimpleSettingsExtension)
        
        try:
            SIPApplication.start(self, FileStorage(options.config_directory or config_directory))
        except ConfigurationError as e:
            show_notice("Failed to load sipclient's configuration: %s\n" % str(e), bold=False)
            show_notice("If an old configuration file is in place, delete it or move it and recreate the configuration using the sip_settings script.", bold=False)
            ui.stop()
            self.stopped_event.set()
        else:
            show_notice("SDK version %s, core version %s.%s, PJSIP version %s (rev %s)" % (version, CORE_REVISION, CORE_BUILD, PJ_VERSION.decode(), PJ_SVN_REVISION))  

    # notification handlers
    #

    def _NH_SIPApplicationWillStart(self, notification):
        account_manager = AccountManager()
        notification_center = NotificationCenter()
        settings = SIPSimpleSettings()
        ui = UI()

        settings.logs.trace_sip = self.options.trace_sip or settings.logs.trace_sip
        settings.logs.trace_msrp = self.options.trace_msrp or settings.logs.trace_msrp
        settings.logs.trace_pjsip = self.options.trace_pjsip or settings.logs.trace_pjsip
        settings.logs.trace_notifications = self.options.trace_notifications or settings.logs.trace_notifications

        for account in account_manager.iter_accounts():
            if isinstance(account, Account):
                account.sip.register = False
                account.presence.enabled = False
                account.xcap.enabled = False
                account.message_summary.enabled = False

            notification_center.add_observer(self, sender=account)

        if self.options.account is None:
            self.account = account_manager.default_account
        else:
            possible_accounts = [account for account in account_manager.iter_accounts() if self.options.account in account.id and account.enabled]
            if len(possible_accounts) > 1:
                show_notice('More than one account exists which matches %s: %s' % (self.options.account, ', '.join(sorted(account.id for account in possible_accounts))), bold=False)
                self.stop()
                return
            elif len(possible_accounts) == 0:
                show_notice('No enabled account which matches %s was found. Available and enabled accounts: %s' % (self.options.account, ', '.join(sorted(account.id for account in account_manager.get_accounts() if account.enabled))), bold=False)
                self.stop()
                return
            else:
                self.account = possible_accounts[0]

        if isinstance(self.account, Account):
            self.account.sip.register = True

        show_notice('Using account %s' % self.account.id, bold=False)
        ui.prompt = Prompt(self.account.id, foreground='default')

        self.logger.start()
        if settings.logs.trace_sip and self.logger._siptrace_filename is not None:
            show_notice('Logging SIP trace to file "%s"' % self.logger._siptrace_filename, bold=False)
        if settings.logs.trace_msrp and self.logger._msrptrace_filename is not None:
            show_notice('Logging MSRP trace to file "%s"' % self.logger._msrptrace_filename, bold=False)
        if settings.logs.trace_pjsip and self.logger._pjsiptrace_filename is not None:
            show_notice('Logging PJSIP trace to file "%s"' % self.logger._pjsiptrace_filename, bold=False)
        if settings.logs.trace_notifications and self.logger._notifications_filename is not None:
            show_notice('Logging notifications trace to file "%s"' % self.logger._notifications_filename, bold=False)

        if self.options.disable_sound:
            settings.audio.input_device = None
            settings.audio.output_device = None
            settings.audio.alert_device = None

    def _NH_TLSTransportHasChanged(self, notification):
        show_notice('TLS verify server: %s\n' % notification.data.verify_server)
        show_notice('TLS certificate: %s\n' % notification.data.certificate)
        if notification.data.certificate:
            try:
                contents = open(os.path.expanduser(notification.data.certificate), 'rb').read()
                certificate = X509Certificate(contents) # validate the certificate
            except (GNUTLSError, OSError) as e:
                show_notice('Cannot read TLS certificate: %s\n' % str(e))
                pass
            else:
                show_notice('TLS Subject: %s\n' % certificate.subject)

    def _NH_MediaStreamDidFail(self, notification):
        reason = notification.data.reason

        if hasattr(notification.data, 'transport'):     
            reason = reason + " transport=%s" % notification.data.transport

        if hasattr(notification.data, 'credentials'):     
            reason = reason + " verify_server=%s" % notification.data.credentials.verify_peer
        show_notice('%s media stream failed: %s\n' % (notification.sender.type, reason))

    def _zrtp_signing_keys_provider(self, session, role):
        """Provider registered with python-sipsimple's SylkZRTPSession at
        application start. Called once per ZRTP session construction so
        v3 signed-handshake automatically uses the same PGP material we
        already cache for chat encryption — no /zrtp_pgp_keys command
        needed per call.

        Returns (local_priv_blob, peer_pub_blob) as armored strings,
        either or both may be None. Exceptions are swallowed; provider
        errors must never abort a call (degrades to v2 semantics).

        Local private key: read from account.sms.private_key (file path
        stamped in by /import_private_key on first set-up).

        Peer public key: read from {self.keys_path}/<peer_aor>.pubkey,
        the same file _NH_SIPSessionGotMessage writes when the peer (or
        the Sylk server's lookup endpoint) sends us their public key.
        """
        local_blob = None
        try:
            account = getattr(session, 'account', None)
            sms = getattr(account, 'sms', None) if account is not None else None
            priv_path = getattr(sms, 'private_key', None) if sms is not None else None
            if priv_path and os.path.exists(priv_path):
                with open(priv_path, 'r') as fd:
                    local_blob = fd.read()
        except Exception as e:
            log.warning('[sylk-zrtp] keys provider: local-priv read failed: %s' % e)

        peer_blob = None
        try:
            # Build the canonical 'user@host' lookup key from
            # session.remote_identity.uri — same shape python-sipsimple's
            # internal _peer_aor_from_session uses for storage keying.
            ri = getattr(session, 'remote_identity', None)
            uri = getattr(ri, 'uri', None) if ri is not None else None
            if uri is not None:
                user = uri.user.decode() if isinstance(uri.user, bytes) else uri.user
                host = uri.host.decode() if isinstance(uri.host, bytes) else uri.host
                peer_aor = '%s@%s' % (user, host)
                pub_path = '%s/%s.pubkey' % (self.keys_path, peer_aor)
                if os.path.exists(pub_path):
                    with open(pub_path, 'r') as fd:
                        peer_blob = fd.read()
        except Exception as e:
            log.warning('[sylk-zrtp] keys provider: peer-pub read failed: %s' % e)

        return (local_blob, peer_blob)

    def _NH_SIPApplicationDidStart(self, notification):
        # Wire automatic v3 PGP key plumbing into python-sipsimple's
        # SylkZRTPSession. Without this hook, set_signing_keys() only
        # ran via the manual /zrtp_pgp_keys command, so every call
        # logged the "peer sent v3 sig but no peer_pub_key plumbed in"
        # warning and v3 verify was effectively a no-op.
        from sipsimple.streams.rtp.sylk_zrtp import register_signing_keys_provider
        register_signing_keys_provider(self._zrtp_signing_keys_provider)

        settings = SIPSimpleSettings()
        engine = Engine()
        # Force outbound video to 480p (640x480) regardless of saved settings.
        forced_resolution = VideoResolution('640x480')
        if settings.video.resolution != forced_resolution:
            settings.video.resolution = forced_resolution
            settings.save()
        engine.set_video_options(settings.video.resolution,
                                 settings.video.framerate,
                                 settings.video.max_bitrate)
        show_notice('Available audio codecs: %s\n' % ', '.join([codec.decode() for codec in engine._ua.available_codecs]))
        show_notice('Available video codecs: %s\n' % ', '.join([codec.decode() for codec in engine._ua.available_video_codecs]))

        self.ip_address_monitor.start()
        self._start_radio_ptt()

        if self.enable_playback:
            show_notice("Polling %s for wav files" % self.playback_dir)
            self.playback_queue.start()
            reactor.callLater(3, self.poll_playback_directory)

        # set the file transfer directory if it's not set
        if settings.file_transfer.directory is None:
            settings.file_transfer.directory = 'file_transfers'

        # display a list of available devices
        self._CH_devices()

        show_notice('Type /help to see a list of available commands.', bold=False)

        if settings.tls.ca_list is None:
            show_notice('Initializing default TLS certificates and settings')
            copy_default_certificates()
            settings.tls.ca_list = os.path.join(config_directory, 'tls/ca.crt')
            settings.tls.certificate = os.path.join(config_directory, 'tls/default.crt')
            settings.tls.verify_server = True
            settings.save()

        if self.target is None and getattr(self.options, 'join_when_invited', None) is not None:
            allowed = self.options.join_when_invited
            show_notice(_ts() + 'Waiting to be invited to %s' % (', '.join(allowed) if allowed else 'any conference'))

        if self.target is not None:
            if self.filepath:
                self.send_outgoing_file(self.target, self.filepath)
                self.must_exit = True
            else:
                # -v / --video toggles the video stream on the
                # outgoing call.  Audio is always on.
                #
                # Chat stream policy:
                #   * default      → audio only, NO MSRP/chat in the
                #                    initial INVITE (chat can still be
                #                    added later via re-INVITE/proposal).
                #   * --chat       → force include chat at call start.
                #   * --no-chat    → explicit form of the default
                #                    (kept for backwards compatibility).
                #   * --video      → audio + video, still no chat unless
                #                    --chat is given.
                want_video = bool(getattr(self.options, 'with_video', False))
                forced_chat = bool(getattr(self.options, 'with_chat', False))
                want_chat = forced_chat
                if want_video:
                    self.enable_video = True
                call_initializer = OutgoingCallInitializer(
                    self.account, self.target,
                    audio=True, chat=want_chat, video=want_video,
                    auto_reconnect=self.options.auto_reconnect,
                )
                call_initializer.start()

    def poll_playback_directory(self):
        if self.outgoing_session:
            reactor.callLater(1, self.poll_playback_directory)
            return

        files = list(filter(os.path.isfile, glob.glob(self.playback_dir + "/*.wav")))
        files.sort(key=lambda x: os.path.getmtime(x))
        for file in files:
            if len(file.split('@')) == 2:
                active_playback_dir = self.playback_dir + '/active'
                basename = os.path.basename(file)
                show_notice("Audio recording detected %s" % basename)
                filename = '%s/%s-%s' % (active_playback_dir, datetime.now().strftime("%Y%m%d-%H%M%S"), basename)
                os.replace(file, filename)
                play_object = {'target': os.path.splitext(basename)[0], 
                               'filename': filename}
                self.playback_queue.put(play_object)

        reactor.callLater(1, self.poll_playback_directory)
        
    def _handle_outgoing_playback(self, play_object):
        self.play_file = play_object['filename']
        call_initializer = OutgoingCallInitializer(self.account, play_object['target'], audio=True, play_file=self.play_file)
        call_initializer.start()
                
    def _start_radio_ptt(self):
        settings = SIPSimpleSettings().radio_ptt
        if not settings.on_command:
            return
        # Accepting the PTT content type inside CPIM is both what lets the
        # commands from the focus through and how the focus learns that this
        # endpoint drives a radio (it shows up in a=accept-wrapped-types).
        if radio_ptt.CONTENT_TYPE not in ChatStream.accept_wrapped_types:
            ChatStream.accept_wrapped_types = ChatStream.accept_wrapped_types + [radio_ptt.CONTENT_TYPE]
        self.radio_ptt = radio_ptt.RadioPTTEndpoint(settings.on_command, settings.off_command, settings.status_file,
                                                    timeout=settings.timeout, confirm_timeout=settings.confirm_timeout,
                                                    poll_interval=settings.poll_interval,
                                                    log=lambda text: show_notice(text, bold=False))
        self.radio_ptt.start()
        if _session_supports_contact_parameters():
            show_notice('Radio PTT: advertising Contact %s and MSRP %s' % (radio_ptt.FEATURE_TAG, radio_ptt.CONTENT_TYPE), bold=False)
        else:
            show_notice('Radio PTT: installed sipsimple has no Session.connect(contact_parameters), '
                        'advertising MSRP %s only (no Contact %s)' % (radio_ptt.CONTENT_TYPE, radio_ptt.FEATURE_TAG), bold=False)

    def _radio_ptt_chat_stream(self, session):
        if self.radio_ptt is None or not getattr(session, 'remote_focus', False):
            return None
        return next((stream for stream in session.streams or [] if stream.type == 'chat'), None)

    def _NH_SIPApplicationWillEnd(self, notification):
        show_notice('Application will end')
        self.stopping = True
        if self.pending_reconnect is not None and self.pending_reconnect.active():
            self.pending_reconnect.cancel()
        self.pending_reconnect = None
        self.ip_address_monitor.stop()
        if self.radio_ptt is not None:
            self.radio_ptt.stop()
        # The SDK shutdown waits for every session to terminate; one stuck
        # in DNS or INVITE never does and SIPApplicationDidEnd never comes.
        # Do not leave the user at a dead prompt.
        watchdog = Timer(self.options.shutdown_timeout, self._force_exit)
        watchdog.daemon = True
        watchdog.start()

    def _force_exit(self):
        if self.stopped_event.is_set():
            return
        show_notice('Shutdown did not complete in %s seconds, forcing exit' % self.options.shutdown_timeout)
        try:
            UI().stop()
        except Exception:
            pass
        sleep(0.2)
        # os._exit() skips the atexit handler the UI registered to put the
        # terminal back in cooked/echo mode, so restore it here.
        _restore_terminal()
        os._exit(1)

    def _NH_SIPApplicationDidEnd(self, notification):
        ui = UI()
        ui.stop()
        self.stopped_event.set()

    def _NH_SIPEngineDetectedNATType(self, notification):
        SIPApplication._NH_SIPEngineDetectedNATType(self, notification)
        if notification.data.succeeded:
            show_notice('Detected NAT type: %s' % notification.data.nat_type)

    def _NH_UIInputGotCommand(self, notification):
        handler = getattr(self, '_CH_%s' % notification.data.command, None)
        if notification.data.command in OTR_COMMANDS and not self.otr_enabled:
            # Hidden, not refused: the account does not do OTR, so these
            # are not commands this program has. Anything else would be a
            # command that the help does not list and that cannot work.
            handler = None
        if handler is not None:
            try:
                handler(*notification.data.args)
            except TypeError:
                show_notice('Illegal use of command /%s. Type /help for a list of available commands.' % notification.data.command)
        else:
            show_notice('Unknown command /%s. Type /help for a list of available commands.' % notification.data.command)

    def _NH_RTPStreamICENegotiationStateDidChange(self, notification):
        data = notification.data
        stype = notification.sender.type.title()
        if data.state == 'GATHERING':
            show_notice("Gathering %s ICE Candidates..." % stype)
        elif data.state == 'NEGOTIATION_START':
            show_notice("Probing remote %s ICE candidates..." % stype)
        elif data.state == 'NEGOTIATING':
            show_notice("%s ICE negotiating in progress..." % stype)
        elif data.state == 'GATHERING_COMPLETE':
            show_notice("%s ICE candidates gathering complete" % stype)
        elif data.state == 'RUNNING':
            show_notice("%s ICE negotiation succeeded" % stype)
        elif data.state == 'FAILED':
            show_notice("%s ICE negotiation failed" % stype, True)

    def _NH_RTPStreamICENegotiationDidFail(self, notification):
        stream = notification.sender
        show_notice('%s ICE negotiation failed: %s' % (notification.sender.type, notification.data.reason))

    def _NH_RTPStreamICENegotiationDidSucceed(self, notification):
        stream = notification.sender
        show_notice('%s ICE negotiation succeeded' % stream.type)
        show_notice('%s RTP endpoints: %s:%d (%s) <-> %s:%d (%s)' % (stream.type, stream.local_rtp_address,
                                                                        stream.local_rtp_port,
                                                                        self.stream.local_rtp_candidate.type.lower(),
                                                                        stream.remote_rtp_address,
                                                                        stream.remote_rtp_port,
                                                                        stream.remote_rtp_candidate.type.lower()))

        if stream.local_rtp_candidate.type.lower() != 'relay' and stream.remote_rtp_candidate.type.lower() != 'relay':
            show_notice('%s stream is peer to peer' % stream.type)
        else:
            show_notice('%s stream is relayed by server' % stream.type)

    def handle_private_keys(self, password, key):
        account = key['account']
        private_key_encrypted = key['private_key']
        public_key = key['public_key']

        try:
            pgpMessage = pgpy.PGPMessage.from_blob(private_key_encrypted.encode())
            decryptedKeyPair = pgpMessage.decrypt(password)
            private_key = decryptedKeyPair.message

            private_key_path = "%s/%s.privkey" % (self.keys_path, account.id)
            fd = open(private_key_path, "wb+")
            fd.write(private_key.encode())
            fd.close()

            show_notice("Private key saved to %s" % private_key_path)

            public_key_path = "%s/%s.pubkey" % (self.keys_path, account.id)
            fd = open(public_key_path, "wb+")
            fd.write(public_key.encode())
            fd.close()

            show_notice("Public key saved to %s, key ID %s"
                        % (public_key_path, public_key_short_checksum(public_key)))

            account.sms.private_key = private_key_path
            account.sms.public_key = public_key_path
            account.sms.public_key_checksum = hashlib.sha1(public_key.encode()).hexdigest()
            account.save()

            # drop cached copies so the new key is picked up
            self.private_keys.pop(account.id, None)
            self.public_keys.pop(str(account.id), None)
            return True

        except Exception as e:
            show_notice("Import private key failed: %s" % str(e))
            return False
            
    def get_public_key(self, uri):
        public_key = None
        try:
            public_key = self.public_keys[uri]
        except KeyError:
            public_key_path = "%s/%s.pubkey" % (self.keys_path, uri)

            if os.path.exists(public_key_path):
                try:
                    public_key, _ = pgpy.PGPKey.from_file(public_key_path)
                except Exception as e:
                    pass
                else:
                    self.public_keys[uri] = public_key
                    show_notice('PGP public key of %s imported from %s, key ID %s'
                                % (uri, public_key_path, read_key_id(public_key_path)))

        return public_key

    def _lookup_public_key(self, target_uri):
        """Ask the SIP server for the PGP public key associated with target_uri.

        Uses the application/sylk-api-pgp-key-lookup content-type that the Sylk
        server understands (same as Blink). The server replies with a separate
        text/pgp-public-key MESSAGE, which the existing inbound handler routes
        to either storage (peer key) or a notice (own-account key)."""
        if isinstance(self.account, BonjourAccount):
            return
        if not target_uri or '@' not in target_uri:
            return
        try:
            message_session = self.message_session(target_uri, silent=True)
        except Exception as e:
            show_notice('Public key lookup for %s failed: %s' % (target_uri, e))
            return
        if message_session is None:
            return
        try:
            message_session.send_message('Public key lookup',
                                         content_type='application/sylk-api-pgp-key-lookup')
        except Exception as e:
            show_notice('Public key lookup for %s failed: %s' % (target_uri, e))

    def _NH_UIInputDidChange(self, notification):
        """The user typed into the input line: keep the remote party's
        typing indicator alive, or put it out.

        Two kinds of line say nothing is being composed and are skipped:
        one starting with the command sequence -- a command is an
        instruction to this program, and the remote party will never see a
        character of it -- and an empty one, which is what is left when the
        user erases a message rather than sending it. Both stop an
        indication we already sent instead of merely not sending one: the
        remote is showing "is composing" on our word and only our word can
        take it back.
        """
        if self.active_session is not None and any(stream.type == 'chat' for stream in self.active_session.streams):
            # The line is going out over MSRP rather than as a MESSAGE --
            # _NH_UIInputGotText prefers the active session -- so it is not
            # this code's to report on.
            return

        text = notification.data.text or ''
        if not self.message_session_to or not text or text.startswith(UI().command_sequence):
            self.stop_composing()
            return

        session = next((session for session in self.message_sessions
                        if session.target == self.message_session_to
                        and session.account == self.account
                        and not session.ended), None)
        if session is None:
            # Nothing to tell yet: the session is made by /message, and a
            # keystroke is not a reason to build one and look its route up.
            return

        if session is not self.composing_session:
            # Typing at someone else now. The previous conversation is owed
            # an idle: nothing else will ever tell it we walked away.
            self.stop_composing()

        now = monotonic()
        if self.composing_refreshed is None or now - self.composing_refreshed >= self.COMPOSING_REFRESH_INTERVAL:
            session.send_composing_indication('active', self.COMPOSING_REFRESH)
            self.composing_session = session
            self.composing_refreshed = now

        self._restart_composing_timer()

    def _restart_composing_timer(self):
        """(Re)arm the countdown that says the user stopped typing."""
        if self.composing_timer is not None:
            self.composing_timer.cancel()
        self.composing_timer = Timer(self.COMPOSING_IDLE_TIMEOUT, self.stop_composing)
        self.composing_timer.daemon = True
        self.composing_timer.start()

    def stop_composing(self, notify=True):
        """Stop claiming that the user is typing.

        `notify` is False when something else already told the remote party
        as much -- sending the message being the only case: the message IS
        the end of composing it, and every client stops the indicator when
        one arrives, so an idle indication after it is a second SIP request
        that buys nothing.
        """
        if self.composing_timer is not None:
            self.composing_timer.cancel()
            self.composing_timer = None

        session, self.composing_session = self.composing_session, None
        self.composing_refreshed = None
        if session is None or session.ended or not notify:
            return
        session.send_composing_indication('idle', self.COMPOSING_REFRESH)

    def _NH_UIInputGotText(self, notification):
        msrp_chat = None
        message_text = notification.data.text
        local_identity = '%s@%s' % (self.account.id.username, self.account.id.domain)
        encrypted = False

        if self.received_private_key:
            pincode = message_text.strip()
            if not pincode:
                show_notice('Enter the 6-digit pincode shown on the other device, or type /hangup to cancel:')
                return
            if self.handle_private_keys(pincode, self.received_private_key):
                self.received_private_key = None
                show_notice('Private key imported successfully')
            else:
                show_notice('Wrong pincode. Try again, or type /hangup to cancel.')
            return

        if self.active_session is not None:
            msrp_chat = next((stream for stream in self.active_session.streams if stream.type == 'chat'), None)

            if msrp_chat:
                msrp_chat.send_message(message_text)

                if msrp_chat.local_identity.display_name:
                    local_identity = msrp_chat.local_identity.display_name
                else:
                    local_identity = str(msrp_chat.local_identity.uri)
            else:
                # compose SMS message
                pass
                
        elif self.message_session_to:
            # compose SMS message
            self.stop_composing(notify=False)
            message_session = self.message_session(self.message_session_to)
            imdn_id = str(uuid.uuid4())
            msg_id = message_session.send_message(message_text, imdn_id=imdn_id)

            pgp_available = (
                getattr(self.account.sms, 'enable_pgp', True)
                and self.get_public_key(message_session.remote_uri) is not None
            )

            if message_session.encryption.active:
                label = 'OTR encrypted text to   %s' % self.message_session_to
            elif pgp_available:
                label = 'PGP encrypted text to   %s' % self.message_session_to
            else:
                label = 'clear text to   %s' % self.message_session_to

            prefix = '%s %s: ' % (datetime.now().replace(microsecond=0), label)

            ui = UI()
            ui.write_keyed(imdn_id, RichText(prefix, foreground='darkred') + message_text)
            return

        else:
            show_notice('No active chat or message session')
            return

        ui = UI()
        ui.write(RichText('%s> ' % local_identity, foreground='darkred') + message_text)

    @property
    def otr_enabled(self):
        return otr_enabled_for(self.account)

    def message_session(self, recipient, account=None, route=None, silent=False):
        account = account or self.account

        # A session with our own address is the channel this client uses to
        # talk to the server about itself (key lookups, key uploads, private
        # key export, the server's replies to any of them). It is never a
        # conversation the user opened, whichever code path asks for it, so
        # it neither announces itself nor gets promoted into one below.
        if self.is_own_address(recipient, account):
            silent = True

        try:
            message_session = next((session for session in self.message_sessions if session.target == recipient and account == session.account))
        except StopIteration:
            message_session = MessageSession(account, recipient, route, silent=silent)
            self.message_sessions.add(message_session)
        else:
            if message_session.silent and not silent:
                # It was opened for key machinery and is now the user's
                # conversation: announce it as if it had just started.
                message_session.silent = False
                message_session.announce()

        return message_session

    def is_own_address(self, target, account=None):
        """Is target this account's own address, however it was spelled?"""
        account = account or self.account
        if not target or isinstance(account, BonjourAccount):
            return False
        target = str(target)
        if target.startswith('sip:') or target.startswith('sips:'):
            target = target.split(':', 1)[1]
        target = target.split(';')[0]
        if '@' not in target:
            target = '%s@%s' % (target, account.id.domain)
        return target.lower() == str(account.id).lower()

    def _NH_SIPSessionTransferNewIncoming(self, notification):
        target = "%s@%s" % (notification.data.transfer_destination.user.decode(), notification.data.transfer_destination.host.decode())
        show_notice('Session transfer requested by remote party to: %s' % target)
        self.question = Question("Call transfer request to %s, do you want to accept? (a)ccept/(r)eject" % target, 'arbi', bold=True)
        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=self.question)
        ui = UI()
        ui.add_question(self.question)

    def _NH_UIQuestionGotAnswer(self, notification):
        notification_center = NotificationCenter()
        ui = UI()
        notification_center.remove_observer(self, sender=notification.sender)
        answer = notification.data.answer
        self.question = None

        # PGP key (re)generation confirmation
        if self.pending_key_generate_account is not None:
            account = self.pending_key_generate_account
            self.pending_key_generate_account = None
            if answer == 'y':
                self._generate_pgp_key(account)
            else:
                show_notice('PGP key generation cancelled')
            return

        # PGP private-key import: user just answered accept/reject
        if self.received_private_key is not None:
            if answer == 'a':
                show_notice('Enter the 6-digit pincode shown on the other device, then press Enter:')
            else:
                self.received_private_key = None
                show_notice('Private key import cancelled')
            return

        if answer == 'a':
            if self.active_session:
                ui.status = 'Accepting transfer...'
                try:
                    self.active_session.accept_transfer()
                except IllegalStateError:
                    pass
        elif answer == 'r':
            if self.active_session:
                ui.status = 'Rejecting transfer...'
                try:
                    self.active_session.reject_transfer()
                except IllegalStateError:
                    pass

    def _NH_SIPEngineGotException(self, notification):
        lines = ['An exception occured within the SIP core:']
        lines.extend(notification.data.traceback.split('\n'))
        show_notice(lines)

    def _NH_SIPAccountRegistrationDidSucceed(self, notification):
        account = notification.sender

        try:
            s = self.registration_succeeded[account.id]
        except KeyError:
            pass
        else:
            if s and notification.data.expires == 0:
                show_notice('%s Registration ended for %s' % (datetime.now().replace(microsecond=0), notification.sender.id))
                return

        contact_header = notification.data.contact_header
        contact_header_list = notification.data.contact_header_list
        expires = notification.data.expires
        registrar = notification.data.registrar
        now = datetime.now().replace(microsecond=0)
        if not self.connected_sessions:
            lines = ['%s Registered contact "%s" of %s at %s:%d;transport=%s for %d seconds' % (now, contact_header.uri, account.id, registrar.address, registrar.port, registrar.transport, expires)]
            if len(contact_header_list) > 1:
                lines.append('%s Other registered contacts of %s:' % (now, account.id))
                lines.extend('%s     %s of %s for %s seconds' % (now, str(other_contact_header.uri), account.id, other_contact_header.expires) for other_contact_header in contact_header_list if other_contact_header.uri != notification.data.contact_header.uri)

            if account.contact.public_gruu is not None:
                lines.append('%s     Public GRUU: %s' % (now, account.contact.public_gruu))

            if account.contact.temporary_gruu is not None:
                lines.append('%s  Temporary GRUU: %s' % (now, account.contact.temporary_gruu))

            show_notice(lines)

        first_registration = not self.registration_succeeded.get(account.id, False)
        self.registration_succeeded[account.id] = True

        # On the first successful registration, ask the server whether it
        # already has a PGP public key for this account. If it does and we
        # have nothing local, the inbound handler will inform the user.
        if first_registration and hasattr(account, 'sms') and not isinstance(account, BonjourAccount):
            self._lookup_public_key(str(account.id))

    def _NH_SIPAccountRegistrationDidFail(self, notification):
        account = notification.sender
        if notification.data.error == self.last_failure_reason:
            return

        self.last_failure_reason = notification.data.error
        if self.active_session is None:
            show_notice('%s Failed to register contact for %s: %s (retrying in %.2f seconds)' % (datetime.now().replace(microsecond=0), account.id, notification.data.error, notification.data.retry_after))
        self.registration_succeeded[notification.sender.id] = False

    def _NH_SIPAccountRegistrationDidEnd(self, notification):
        account = notification.sender
        show_notice('%s Registration ended for %s' % (datetime.now().replace(microsecond=0), account.id))

    def _NH_BonjourAccountRegistrationDidSucceed(self, notification):
        show_notice('%s Registered Bonjour contact %s' % (datetime.now().replace(microsecond=0), notification.data.name))

    def _NH_BonjourAccountRegistrationDidFail(self, notification):
        if notification.data.reason == self.last_failure_reason:
            return

        self.last_failure_reason = notification.data.reason
        #show_notice('%s Failed to register Bonjour contact: %s' % (datetime.now().replace(microsecond=0), notification.data.reason))

    def _NH_BonjourAccountRegistrationDidEnd(self, notification):
        pass
        #show_notice('%s Bonjour registration ended for %s' % (datetime.now().replace(microsecond=0), notification.sender.id))

    def _NH_BonjourAccountDidAddNeighbour(self, notification):
        neighbour, record = notification.data.neighbour, notification.data.record
        now = datetime.now().replace(microsecond=0)
        #show_notice('%s Discovered Bonjour neighbour: "%s (%s)" <%s>' % (now, record.name, record.host, record.uri))
        self.neighbours[neighbour] = BonjourNeighbour(neighbour, record.uri, record.name, record.host)

    def _NH_BonjourAccountDidUpdateNeighbour(self, notification):
        neighbour, record = notification.data.neighbour, notification.data.record
        now = datetime.now().replace(microsecond=0)
        try:
            bonjour_neighbour = self.neighbours[neighbour]
        except KeyError:
            #show_notice('%s Discovered Bonjour neighbour: "%s (%s)" <%s>' % (now, record.name, record.host, record.uri))
            self.neighbours[neighbour] = BonjourNeighbour(neighbour, record.uri, record.name, record.host)
        else:
            #show_notice('%s Updated Bonjour neighbour: "%s (%s)" <%s>' % (now, record.name, record.host, record.uri))
            bonjour_neighbour.display_name = record.name
            bonjour_neighbour.host = record.host
            bonjour_neighbour.uri = record.uri

    def _NH_BonjourAccountDidRemoveNeighbour(self, notification):
        neighbour = notification.data.neighbour
        now = datetime.now().replace(microsecond=0)
        try:
            bonjour_neighbour = self.neighbours.pop(neighbour)
        except KeyError:
            pass
        else:
            pass
            #show_notice('%s Bonjour neighbour left: "%s (%s)" <%s>' % (now, bonjour_neighbour.display_name, bonjour_neighbour.host, bonjour_neighbour.uri))

    def _NH_SIPEngineGotMessage(self, notification):
        data = notification.data

        call_id = data.headers.get('Call-ID', Null).body
        try:
            instance_id = data.from_header.uri.parameters.get('instance_id', None).split(":")[2]
        except (IndexError, AttributeError):
            instance_id = None

        try:
            self.received_message_ids.remove(call_id)
        except KeyError:
            self.received_message_ids.add(call_id)
        else:
            # drop duplicate message received
            return

        content_type = data.content_type

        if content_type not in ('text/plain', 'text/html', 'message/cpim', IsComposingDocument.content_type, 'text/pgp-private-key', 'text/pgp-public-key'):
            return

        account = AccountManager().find_account(data.to_header.uri)
        
        if not account:
            return

        is_composing = False
        private_key = None

        try:
            private_key = self.private_keys[account.id]
        except KeyError:
            try:
                private_key, _ = pgpy.PGPKey.from_file(account.sms.private_key)
            except Exception as e:
                pass
            else:
                show_notice('PGP private key imported from %s' % account.sms.private_key)
                self.private_keys[account.id] = private_key
                
        from_header = FromHeader.new(data.from_header)

        identity = '%s@%s' % (from_header.uri.user.decode(), from_header.uri.host.decode())
        remote_uri = identity
        if isinstance(account, BonjourAccount):
            via = data.headers.get('Via', Null)[0]
            route = Route(address=via.host, port=via.port, transport=via.transport.lower())
        else:
            route = None

        if data.headers.get('X-Replicated-Message', Null).body:
           return

        if content_type not in ('text/pgp-private-key', 'text/pgp-public-key'):
            message_session = self.message_session(identity, account=account, route=route)
            parsed_payload = None
            try:
                parsed_payload = message_session.handle_incoming_message(data.from_header, content_type, data.body, call_id)
                (content_type, content, identity, msg_id, encrypted, imdn_id, cpim_imdn_events, imdn_timestamp, sender_identity) = parsed_payload            
            except Exception as e:
                pass
                #show_notice('Parse exception from %s content_type %s, call_id %s: %s' % (data.from_header, content_type, call_id, str(e)))

            #show_notice("Parsed payload %s" % parsed_payload)

            #if content_type not in ('text/pgp-private-key', 'text/pgp-public-key'):
            #    self.message_session_to = None
            #    message_session.end()
            #    try:  
            #        self.message_sessions.remove(message_session)
            #    except KeyError:
            #        pass
            if parsed_payload is None:
                return

            # Internal control messages should not be rendered in the chat.
            if content_type == 'application/sylk-api-pgp-key-lookup':
                return

        else:
            content = data.body
            encrypted = False
            message_session = None

        if content_type not in ('text/pgp-private-key', 'text/pgp-public-key', IsComposingDocument.content_type):
            previous_session_to = self.message_session_to
            self.message_session_to = identity
            if previous_session_to != identity:
                self._update_prompt()

        content_encoding = data.headers.get('Content-Encoding', Null).body
        if content_encoding == 'deflate':
            try:
                content = zlib.decompress(content)
            except zlib.error as e:
                show_notice('Cannot decompress message: %s' % str(e))
                return
        
        if content_type == IsComposingDocument.content_type:
            # body must not be utf-8 decoded
            msg = IsComposingMessage.parse(content)
            state = msg.state.value
            refresh = msg.refresh.value if msg.refresh is not None else None
            content_type = msg.content_type.value if msg.content_type is not None else None
            last_active = msg.last_active.value if msg.last_active is not None else None
            #show_notice('Is composing state=%s, refresh=%s, last_active=%s' % (state, refresh, last_active))

            if state == 'active':
                if refresh is None:
                    refresh = 120

                if last_active is not None and (last_active - ISOTimestamp.now() > timedelta(seconds=refresh)):
                    # message is old, discard it
                    return
            elif state == 'idle':
                return

            is_composing = True

        if from_header.display_name and from_header.display_name != identity:
            identity = '"%s" <%s>' % (from_header.display_name, identity)

        content = content.decode() if isinstance(content, bytes) else content
        content = content.strip()
        #show_notice('Message type %s' % content_type)

        if is_composing:
            ui = UI()
            ui.status = "%s is composing a message" % identity
            reactor.callLater(4, setattr, ui, 'status', None)
        else:
            if encrypted:
                show_notice("%s %s wrote: %s (encrypted)" % (datetime.now().replace(microsecond=0), identity, content))
            else:
                if content_type == 'text/pgp-public-key':
                    if content.startswith('-----BEGIN PGP PUBLIC KEY BLOCK-----') and content.endswith('-----END PGP PUBLIC KEY BLOCK-----'):
                        target_id = instance_id or remote_uri
                        own_account = (target_id == str(account.id))

                        if own_account:
                            local_pub_path = getattr(account.sms, 'public_key', None) if hasattr(account, 'sms') else None
                            local_pub = None
                            if local_pub_path and os.path.exists(local_pub_path):
                                try:
                                    with open(local_pub_path, 'r') as fd:
                                        local_pub = fd.read().strip()
                                except (OSError, IOError):
                                    local_pub = None

                            server_pub = content.strip()
                            # Compare the key IDs rather than the armoured
                            # text: the same key can come back with different
                            # line endings or armour headers and still be the
                            # very key we hold.
                            server_key_id = public_key_short_checksum(server_pub)
                            local_key_id = public_key_short_checksum(local_pub) if local_pub else None
                            if not local_pub:
                                show_notice([
                                    'The server already has a PGP public key for your account %s, but no local key was found.' % target_id,
                                    'To read messages encrypted with that key, import your private key from another device:',
                                    '  - On the other device, type /key export. It will show a 6-digit pincode.',
                                    '  - When the encrypted key arrives here, accept it and type the pincode.',
                                    'Or run /key generate to create a new key (this will OVERWRITE the server key; messages encrypted with the old key will be unreadable).',
                                ], bold=True)
                            elif server_key_id == local_key_id:
                                show_notice('The PGP public key on the server for %s is your local key, key ID %s'
                                            % (target_id, server_key_id), bold=False)
                            else:
                                # The two key IDs are what makes this
                                # actionable: they are the same strings the
                                # other devices show, so the user can tell
                                # which of them holds the key on the server.
                                show_notice([
                                    'The PGP public key on the server for %s differs from your local key.' % target_id,
                                    '  server key ID %s, local key ID %s'
                                    % (server_key_id, local_key_id),
                                    'Running /key generate will OVERWRITE the server key with a freshly generated one.',
                                ], bold=True)
                            return

                        public_key_path = "%s/%s.pubkey" % (self.keys_path, target_id)
                        received_key_id = public_key_short_checksum(content)
                        stored_key_id = read_key_id(public_key_path) if os.path.exists(public_key_path) else None

                        if stored_key_id is not None and stored_key_id == received_key_id:
                            # The key we already hold for this person. Every
                            # /m to them asks the server for it, so saying
                            # anything here would be noise on every chat.
                            return

                        try:
                            fd = open(public_key_path, "wb+")
                            fd.write(content.encode())
                            fd.close()
                        except (OSError, IOError) as e:
                            show_notice('Cannot save the PGP public key of %s to %s: %s'
                                        % (target_id, public_key_path, e))
                            return

                        # Put the new key straight into the cache instead of
                        # letting get_public_key() re-read and re-announce the
                        # file we have just written.
                        self.public_keys.pop(target_id, None)
                        try:
                            received_key, _ = pgpy.PGPKey.from_blob(content)
                        except Exception:
                            pass
                        else:
                            self.public_keys[target_id] = received_key

                        if stored_key_id is None:
                            show_notice('PGP public key of %s saved to %s, key ID %s'
                                        % (target_id, public_key_path, received_key_id))
                        else:
                            # A peer key that changed under us is the one case
                            # here worth interrupting for: from now on messages
                            # are encrypted to a key we have never seen.
                            show_notice([
                                'The PGP public key of %s changed: key ID was %s, is now %s.'
                                % (target_id, stored_key_id, received_key_id),
                                'Messages to them will be encrypted to the new key. If you did not expect this, confirm the key ID with %s before sending anything sensitive.' % target_id,
                            ], bold=True)
                        return

                elif content_type == 'application/sylk-file-transfer':
                    try:
                        file_transfer = json.loads(content)
                    except (TypeError, json.decoder.JSONDecodeError):
                        pass 
                    else:
                        try:
                            show_notice("%s %s sent file %s to %s" % (datetime.now().replace(microsecond=0), file_transfer['sender']['uri'], file_transfer['filename'], file_transfer['receiver']['uri']))
                        except KeyError as e:
                            show_notice(file_transfer)
                    return
            
                elif content_type == 'text/pgp-private-key':
                    public_key = ''
                    private_key_encrypted = ''

                    start_public = False
                    start_private = False

                    for l in content.split("\n"):
                        if l == "-----BEGIN PGP PUBLIC KEY BLOCK-----":
                            start_public = True

                        if l == "-----BEGIN PGP MESSAGE-----":
                            start_public = False
                            start_private = True

                        if start_public:
                            public_key = public_key + l + "\n"

                        if start_private:
                            private_key_encrypted = private_key_encrypted + l + "\n"
                            
                    if private_key_encrypted and public_key:
                        self.received_private_key = {'account': account, 'private_key': private_key_encrypted, 'public_key': public_key}
                        self.question = Question("Private key received for account %s, do you want to import it? (a)ccept/(r)eject" % (account.id), 'ar', bold=True)
                        notification_center = NotificationCenter()
                        notification_center.add_observer(self, sender=self.question)
                        ui = UI()
                        ui.add_question(self.question)
                else:
                    if content.startswith('-----BEGIN PGP MESSAGE-----') and content.endswith('-----END PGP MESSAGE-----') and not private_key:
                        show_notice("%s %s wrote: %s" % (datetime.now().replace(microsecond=0), identity, 'Received a PGP message for which we have no private key'))
                        if cpim_imdn_events and imdn_timestamp and self.account.sms.enable_imdn and 'display' in cpim_imdn_events:
                             message_session.send_imdn_notification(imdn_id, imdn_timestamp, identity, sender_identity, 'error')
                    else:
                        try:
                            pgpMessage = pgpy.PGPMessage.from_blob(content.encode())
                            decrypted_message = private_key.decrypt(pgpMessage)
                        except (pgpy.errors.PGPDecryptionError, pgpy.errors.PGPError) as e:
                            show_notice("%s %s wrote: %s" % (datetime.now().replace(microsecond=0), identity, 'PGP decrypt error'))
                            if cpim_imdn_events and imdn_timestamp and self.account.sms.enable_imdn and 'display' in cpim_imdn_events:
                                message_session.send_imdn_notification(imdn_id, imdn_timestamp, identity, sender_identity, 'error')
                            return
                        except (ValueError, NotImplementedError) as e:
                            show_notice("%s %s wrote: %s" % (datetime.now().replace(microsecond=0), identity, content))
                            if cpim_imdn_events and imdn_timestamp and self.account.sms.enable_imdn and 'display' in cpim_imdn_events:
                                message_session.send_imdn_notification(imdn_id, imdn_timestamp, identity, sender_identity, 'displayed')
                        else:
                            msg = decrypted_message.message
                            if isinstance(msg, (bytes, bytearray)):
                                content = msg.decode('utf-8', errors='replace')
                            else:
                                try:
                                    content = bytes(msg, 'latin1').decode('utf-8')
                                except (UnicodeEncodeError, UnicodeDecodeError):
                                    content = msg
                            show_notice("%s PGP encrypted text from %s: %s" % (datetime.now().replace(microsecond=0), identity, content))
                            if cpim_imdn_events and imdn_timestamp and self.account.sms.enable_imdn and 'display' in cpim_imdn_events:
                                message_session.send_imdn_notification(imdn_id, imdn_timestamp, identity, sender_identity, 'displayed')

            if not encrypted and message_session and message_session.encryption and message_session.encryption.active and content_type != IsComposingDocument.content_type:
                show_notice("%s %s stopped the encryption" % (datetime.now().replace(microsecond=0), identity))
                message_session.encryption.stop()

            if content.startswith('?OTR:') and message_session:
                show_notice("%s %s encrypted message could not be read" % (datetime.now().replace(microsecond=0), identity))
                if message_session.otr_enabled:
                    message_session.encryption.stop()

    def _NH_SIPSessionNewIncomingFromTransferFailed(self, notification):
        session = notification.sender
        show_notice('Incoming session with transfer from %s failed: %s' % (str(session.remote_identity.uri), notification.data))

    def _NH_SIPSessionNewIncoming(self, notification):
        session = notification.sender
        transfer_streams = [stream for stream in session.proposed_streams if stream.type == 'file-transfer' and stream.direction == 'recvonly']
        # only allow sessions with 0 or 1 file transfers
        if transfer_streams:
            if len(transfer_streams) > 1:
                session.reject(488)
                return
            transfer_handler = IncomingTransferHandler(session, self.options.auto_answer_interval)
            transfer_handler.start()
        else:
            for stream in notification.data.streams:
                if stream.type in ('audio', 'chat'):
                    break
            else:
                remote_identity = str(session.remote_identity.uri)
                if session.remote_identity.display_name:
                    remote_identity = '"%s" <%s>' % (session.remote_identity.display_name, remote_identity)

                show_notice('Session from %s rejected due to incompatible media' % remote_identity)
                session.reject(415)
                return

            notification_center = NotificationCenter()
            notification_center.add_observer(self, sender=session)
            answer_interval = self.options.auto_answer_interval
            join = self._conference_invitation(session)
            if join is False:
                return
            if join:
                answer_interval = 0
            call_initializer = IncomingCallInitializer(session, answer_interval)
            call_initializer.start()

    def _conference_invitation(self, session):
        # --join-when-invited: a conference server invites us by calling us
        # from the room. Returns True to answer at once, False if the call was
        # rejected here, None to handle it as any other incoming call.
        allowed = getattr(self.options, 'join_when_invited', None)
        if allowed is None:
            return None
        uri = session.remote_identity.uri
        user = uri.user.decode() if isinstance(uri.user, bytes) else uri.user
        host = uri.host.decode() if isinstance(uri.host, bytes) else uri.host
        room = ('%s@%s' % (user, host)).lower()
        if allowed:
            def _matches(watch):
                watch = re.sub('^sips?:', '', watch).lower()
                return room == watch if '@' in watch else room.split('@', 1)[0] == watch
            if not any(_matches(w) for w in allowed):
                return None
        elif not getattr(session, 'remote_focus', False):
            return None

        def _same_room(other):
            if other is session or other.state in (None, 'terminating', 'terminated'):
                return False
            other_uri = other.remote_identity.uri
            other_user = other_uri.user.decode() if isinstance(other_uri.user, bytes) else other_uri.user
            other_host = other_uri.host.decode() if isinstance(other_uri.host, bytes) else other_uri.host
            return ('%s@%s' % (other_user, other_host)).lower() == room

        # The invitation is forked to every registered contact, possibly more
        # than one of ours: answer one and turn the others down.
        joining = [other for other in getattr(self, '_conference_joins', []) if other.state not in (None, 'terminating', 'terminated')]
        if any(_same_room(other) for other in list(self.connected_sessions) + joining):
            show_notice(_ts() + 'Invited to %s again, already joined' % room, bold=False)
            session.reject(486)
            return False
        joining.append(session)
        self._conference_joins = joining
        session._joined_by_invitation = True
        offered = ', '.join(stream.type for stream in session.proposed_streams) or 'no supported media'
        show_notice(_ts() + 'Invited to %s with %s, joining' % (room, offered))
        return True

    def _complete_invitation_media(self, session):
        # The conference server's invitation may carry fewer streams than we
        # asked for on the command line (--chat, --video): add the missing
        # ones once the session is up.
        if session.state != 'connected':
            return
        have = set(stream.type for stream in session.streams or [])
        wanted = []
        if getattr(self.options, 'with_chat', False) and 'chat' not in have:
            wanted.append('chat')
        if getattr(self.options, 'with_video', False) and 'video' not in have:
            wanted.append('video')
        tried = session.__dict__.setdefault('_invitation_media_tried', set())
        wanted = [name for name in wanted if name not in tried]
        if not wanted:
            return
        stream_name = wanted[0]
        tried.add(stream_name)
        try:
            OutgoingProposalHandler(session, **{stream_name: True}).start()
        except (IllegalStateError, ValueError) as e:
            show_notice('Cannot add %s to %s: %s' % (stream_name, session.remote_identity.uri, e))
            return
        show_notice(_ts() + 'Adding %s to the conference session' % stream_name, bold=False)
        if len(wanted) > 1:
            # one proposal at a time: try the next one when this has settled
            reactor.callLater(5, self._complete_invitation_media, session)

    def _NH_SIPSessionNewOutgoing(self, notification):
        session = notification.sender
        transfer_streams = [stream for stream in session.proposed_streams if stream.type == 'file-transfer']
        if not transfer_streams:
            notification_center = NotificationCenter()
            notification_center.add_observer(self, sender=session)

    def _NH_SIPSessionDidFail(self, notification):
        notification_center = NotificationCenter()
        notification_center.discard_observer(self, sender=notification.sender)

        if self.must_exit:
            self.stop()

    def _NH_SIPSessionWillStart(self, notification):
        notification_center = NotificationCenter()
        for stream in notification.sender.proposed_streams:
            notification_center.add_observer(self, sender=stream)

    def _start_pcap_capture(self, session):
        """
        Launch tcpdump for this session.  Filter is built from the
        ports the session actually negotiated — SIP signaling (from
        the engine) plus every RTP/RTCP port on the session's streams.
        Saved to ~/.sipclient/logs/<call_id>/capture.pcap.

        We use a port filter rather than `host <ip>` because the
        signaling peer (registrar / SBC) is usually a different host
        from the media peer; a port filter catches everything the
        sipsimple process touches regardless of remote IP.
        """
        try:
            call_id = _sip_call_id(session) or '?'
            # Prefix the call-id with a YYYYmmdd-HHMMSS local-time
            # stamp so directories sort chronologically and a single
            # call-id replayed across multiple sessions doesn't clash.
            stamp = datetime.now().strftime('%Y%m%d-%H%M%S')
            base = os.path.expanduser(
                '~/.sipclient/logs/%s-%s' % (stamp, call_id)
            )
            makedirs(base)
            pcap_path = os.path.join(base, 'capture.pcap')

            ports = set()
            # SIP signaling ports (best-effort across transports).
            try:
                eng = Engine()
                for attr in ('transport_udp_port', 'transport_tcp_port', 'transport_tls_port'):
                    p = getattr(eng, attr, 0)
                    if p:
                        ports.add(int(p))
            except Exception:
                pass
            # Default well-known SIP ports as a safety net — covers
            # the registrar side of the traffic when the engine ports
            # are dynamic.
            ports.update({5060, 5061})

            # Per-stream RTP/RTCP ports.
            for stream in (session.streams or []):
                lport = getattr(stream, 'local_rtp_port', None)
                rport = getattr(stream, 'remote_rtp_port', None)
                for p in (lport, rport):
                    if p:
                        ports.add(int(p))
                        ports.add(int(p) + 1)  # RTCP

            if not ports:
                show_notice('pcap: no ports learned for the session, skipping capture')
                return

            port_filter = ' or '.join('port %d' % p for p in sorted(ports))

            # Try tcpdump without sudo first — works if the user has
            # been granted BPF access (Wireshark's ChmodBPF launchd
            # item, `sudo chmod o+r /dev/bpf*`, or a sudoers NOPASSWD
            # rule for tcpdump itself).  Only fall back to `sudo -n`
            # (non-interactive) if the direct invocation fails with
            # a permission error — interactive sudo would block the
            # whole sip-session3 reactor thread on the password prompt.
            #
            # On macOS interfaces "any" / "en0" need BPF read access.
            iface_args = ['-i', 'any']
            base_cmd = ['tcpdump'] + iface_args + ['-U', '-w', pcap_path, port_filter]

            def _spawn(cmd):
                try:
                    return subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
                                            stderr=subprocess.PIPE)
                except FileNotFoundError:
                    return None

            proc = _spawn(base_cmd)
            if proc is None:
                show_notice('pcap: tcpdump not found in PATH; install it to capture traffic')
                return

            # If tcpdump exits within 200ms it failed (permission denied,
            # bad interface, ...).  Read stderr and decide whether to
            # retry under `sudo -n`.
            try:
                proc.wait(timeout=0.2)
                err = (proc.stderr.read().decode('utf-8', 'replace').strip()
                       if proc.stderr else '')
                if any(s in err.lower() for s in ('permission denied',
                                                  'operation not permitted',
                                                  'no permission')):
                    sudo_proc = _spawn(['sudo', '-n'] + base_cmd)
                    if sudo_proc is None:
                        show_notice(
                            'pcap: tcpdump failed (%s) and sudo is not '
                            'available either' % err
                        )
                        return
                    try:
                        sudo_proc.wait(timeout=0.2)
                        sudo_err = (sudo_proc.stderr.read()
                                       .decode('utf-8', 'replace').strip()
                                    if sudo_proc.stderr else '')
                        show_notice(
                            'pcap: capture did not start: %s. '
                            'To enable: add a NOPASSWD sudoers rule for '
                            'tcpdump, or `sudo chmod o+r /dev/bpf*`, or '
                            'install Wireshark ChmodBPF.'
                            % (sudo_err or err)
                        )
                        return
                    except subprocess.TimeoutExpired:
                        proc = sudo_proc
                else:
                    show_notice('pcap: capture did not start: %s' % (err or '<no stderr>'))
                    return
            except subprocess.TimeoutExpired:
                pass

            self.pcap_processes[id(session)] = proc
            show_notice('pcap: capturing %d port(s) to %s' % (len(ports), pcap_path))
        except Exception as exc:
            show_notice('pcap: failed to start capture: %s' % exc)

    def _stop_pcap_capture(self, session):
        """
        Terminate the tcpdump process for this session, if any.
        Works whether tcpdump was launched directly or as a child
        of `sudo` — SIGINT propagates through sudo, and we fall
        through to SIGTERM if the child doesn't exit promptly.
        """
        proc = self.pcap_processes.pop(id(session), None)
        if proc is None:
            return
        try:
            proc.send_signal(signal.SIGINT)
            try:
                proc.wait(timeout=3.0)
                return
            except subprocess.TimeoutExpired:
                pass
        except Exception:
            pass
        try:
            proc.terminate()
            proc.wait(timeout=1.0)
        except Exception:
            try:
                proc.kill()
            except Exception:
                pass

    def _NH_SIPSessionDidStart(self, notification):
        session = notification.sender

        self.connected_sessions.append(session)
        radio_stream = self._radio_ptt_chat_stream(session)
        if radio_stream is not None:
            session._radio_ptt_stream = radio_stream
            self.radio_ptt.attach(radio_stream)
        if getattr(session, '_load', False):
            # Load-test leg: keep every call active (never on hold) so all of
            # them keep pushing audio through the conference mixer. Don't hold
            # the current active session and don't steal active status; just
            # start a looping tone so the mixer has real signal to work on.
            if self.active_session is None:
                self.active_session = session
            self._load_start_audio(session)
        else:
            if self.active_session is not None:
                self.active_session.hold()
            self.active_session = session
        self.message_session_to = None

        # --dump: per-call pcap into ~/.sipclient/logs/<call-id>/
        if getattr(self.options, 'dump', False):
            self._start_pcap_capture(session)

        # Auto-spawn an on-screen video window for every video stream.
        # The user can still close it with `/video close` and reopen
        # with `/video open` (see _CH_video).
        has_video = False
        for stream in (session.streams or []):
            if stream.type != 'video':
                continue
            has_video = True
            try:
                identity = str(session.remote_identity.uri)
                window = VideoWindow(title='Video — %s' % identity)
                renderer = FrameBufferVideoRenderer(window.frame_handler)
                renderer.producer = stream.producer
                self.video_windows.setdefault(id(session), []).append(
                    (window, renderer)
                )
                window.start()
                show_notice(
                    'Video window opened for %s — `/video close` to hide' % identity,
                    bold=False,
                )
            except Exception as exc:
                show_notice('Could not open video window: %s' % exc)
        # If there's a video stream we need the RTP stats thread
        # running so the HUD has numbers — independent of whether
        # the user has toggled console RTP printing with `/rtp`.
        if has_video and self.rtp_statistics is None:
            # Auto-started for HUD: console output stays off until
            # the user runs `/rtp on`.
            self.rtp_statistics = RTPStatisticsThread(console=False)
            self.rtp_statistics.start()

        # If the call has no chat stream, automatically start a message
        # session to the same contact so typed text becomes SIP MESSAGEs
        # to the party we're on the line with.
        has_chat = any(stream.type == 'chat' for stream in session.streams)
        if not has_chat:
            try:
                user = session.remote_identity.uri.user
                host = session.remote_identity.uri.host
                if isinstance(user, bytes):
                    user = user.decode()
                if isinstance(host, bytes):
                    host = host.decode()
                remote = '%s@%s' % (user, host)
                message_session = self.message_session(remote, silent=True)
                # This is the user's conversation, but this branch has its
                # own, more informative notice, so it is not announced twice.
                message_session.silent = False
                self.message_session_to = remote
                show_notice('Message session started with %s; typed text will be sent as SIP MESSAGE' % remote, bold=False)
            except Exception as e:
                show_notice('Could not auto-start message session: %s' % e)

        self._update_prompt()
        if len(self.connected_sessions) > 1:
            # this displays the connected sessions
            self._CH_sessions()

        if getattr(session, 'remote_focus', False) and not getattr(session, '_load', False):
            session._hangup_on_leave_seen = set()
            # --invite-on-join is deferred until the first conference-info NOTIFY,
            # so users already in the room are not invited again.
            session._invite_pending = bool(_split_users(self.options.invite))

        if getattr(session, '_joined_by_invitation', False):
            joined = ', '.join(stream.type for stream in session.streams or [])
            show_notice(_ts() + 'Joined %s with %s' % (session.remote_identity.uri, joined))
            reactor.callLater(1, self._complete_invitation_media, session)

        if self.options.auto_hangup_interval is not None:
            if self.options.auto_hangup_interval == 0:
                session.end()
            else:
                timer = reactor.callLater(self.options.auto_hangup_interval, session.end)
                self.hangup_timers[id(session)] = timer

    def _NH_VideoStreamRemoteFormatDidChange(self, notification):
        # The on-screen renderer's FrameBuffer port is created with the
        # remote decoder's output format captured at attach time
        # (FrameBufferVideoRenderer._initialize reads
        # producer.producer_port.info.fmt — see _core.video.pxi).  When we
        # attach the renderer in _NH_SIPSessionDidStart the decoded format
        # is only known yet if a remote frame has already been decoded.
        #
        # For an *incoming* call the caller is usually already sending by
        # the time the session starts, so the format is valid and video
        # shows.  For an *outgoing* call SIPSessionDidStart fires the instant
        # the 200 OK is processed — before any remote frame is decoded — so
        # the renderer locks onto a placeholder format and never displays the
        # real stream.  Re-binding the producer here (None forces a rebuild,
        # since setting the same producer is a no-op) recreates the render
        # port at the now-known size.  This also handles mid-call resolution
        # changes.
        stream = notification.sender
        session = getattr(stream, 'session', None)
        if session is None:
            return
        for window, renderer in self.video_windows.get(id(session), []):
            try:
                renderer.producer = None
                renderer.producer = stream.producer
            except Exception as exc:
                show_notice('Could not refresh video window: %s' % exc)

    def _NH_SIPSessionWillEnd(self, notification):
        notification_center = NotificationCenter()
        session = notification.sender
        if id(session) in self.hangup_timers:
            timer = self.hangup_timers[id(session)]
            if timer.active():
                timer.cancel()
            del self.hangup_timers[id(session)]

    # ---- Sylk-ZRTP-over-MESSAGE observer ------------------------------
    #
    # The full handshake state machine, X25519+HKDF derivation, AEAD key
    # installation on streams, and the X-Sylk-ZRTP capability header now
    # live inside sipsimple.streams.rtp.sylk_zrtp and are driven by the
    # SDK whenever the local account allows it AND the remote peer
    # signaled support. Apps only observe and surface the SAS / state.

    def _NH_SIPSessionSylkZRTPStateChanged(self, notification):
        data = notification.data
        state = getattr(data, 'state', None)
        # Resolve SIP Call-ID for log filtering. Same format the Sylk
        # Mobile [ui] lines use so a single grep correlates both sides
        # of a call.
        session = notification.sender
        cid = _sip_call_id(session)
        # Protocol version (1 or 2) and continuity_state ('first-time' /
        # 'verified' / 'mismatch' / 'one-sided-local' / 'one-sided-peer')
        # are supplied by the SDK on every state notification — surface
        # them so the log trail tells you which wire version was actually
        # negotiated and whether retained-secret continuity was used.
        protocol_version = getattr(data, 'protocol_version', None)
        continuity = getattr(data, 'continuity_state', None)
        meta_bits = []
        if protocol_version is not None:
            meta_bits.append('v%d' % protocol_version)
        if continuity:
            meta_bits.append('continuity=%s' % continuity)
        meta = (' [' + ' '.join(meta_bits) + ']') if meta_bits else ''
        prefix = '[zrtp] call_id=%s%s' % (cid, meta)
        if state == 'key-agreed':
            sas = getattr(data, 'sas', None)
            if sas:
                show_notice('%s key agreed — SAS: %s' % (prefix, sas))
            else:
                show_notice('%s key agreed' % prefix)
            # Device-id breadcrumb. Symmetric with sylk-mobile's
            # SAS-verification dialog "this device / peer" lines and
            # python-sipsimple's "session created" log entry. Surfaces
            # the +sip.instance / settings.instance_id we put on the
            # wire AND the device_id the peer carried in its probe /
            # accept payload, truncated to 16 chars so the prompt area
            # stays readable. '<none>' on either field is the visible
            # tell that per-device rs1 keying is degraded — peer is on
            # an older stack or, for local, our settings.instance_id
            # is empty. Shown at key-agreed (not probing) because
            # callees skip the probing state and on the caller side
            # the peer's device_id isn't yet known when probing fires.
            sylk = getattr(session, '_sylk_zrtp', None)
            if sylk is not None:
                local_did = (getattr(sylk, 'local_device_id', None) or '<none>')[:16]
                peer_did = (getattr(sylk, 'peer_device_id', None) or '<none>')[:16]
                show_notice('%s     this device: %s · peer: %s'
                            % (prefix, local_did, peer_did))
        elif state == 'key-active':
            installed = getattr(data, 'installed_streams', None) or []
            failed = getattr(data, 'failed_streams', None) or []
            if installed:
                install_desc = ', '.join('%s(%s,prefix=%d)' % (typ, codec, vp)
                                          for (typ, codec, vp) in installed)
                show_notice('%s media now end-to-end encrypted (AES-128-GCM) — installed on: %s'
                            % (prefix, install_desc))
            else:
                show_notice('%s media now end-to-end encrypted (AES-128-GCM)' % prefix)
            if continuity == 'mismatch':
                show_notice('%s WARNING — retained-secret mismatch; could be a peer reinstall OR a man-in-the-middle. Verify the SAS in person before continuing.' % prefix)
            elif continuity in ('one-sided-local', 'one-sided-peer'):
                show_notice('%s note — only one side held a retained secret (%s); re-verify SAS to re-bind.' % (prefix, continuity))
            if failed:
                fail_desc = ', '.join('%s(%s,%s)' % (typ, codec, reason)
                                       for (typ, codec, reason) in failed)
                show_notice('%s note — these streams stayed plain: %s' % (prefix, fail_desc))
        elif state == 'failed':
            reason = getattr(data, 'error', None) or getattr(data, 'reason', '')
            failed = getattr(data, 'failed_streams', None) or []
            show_notice('%s handshake failed%s' % (prefix, (': ' + reason) if reason else ''))
            for typ, codec, why in failed:
                show_notice('%s    %s stream (codec=%s) — %s' % (prefix, typ, codec, why))
        elif state == 'probing':
            role = getattr(data, 'role', '?')
            show_notice('%s handshake started (role=%s)' % (prefix, role))
        # Sylk-ZRTP state transitions move us between "encrypted/not
        # verified" and "encrypted/verified (Sylk-ZRTP v2)" — refresh
        # the prompt so the label tracks reality.
        if state in ('key-agreed', 'key-active', 'failed'):
            self._update_prompt()

    def _NH_SIPSessionDidEnd(self, notification):
        notification_center = NotificationCenter()
        session = notification.sender
        notification_center.discard_observer(self, sender=session)
        for stream in session.streams or session.proposed_streams or []:
            notification_center.discard_observer(self, sender=stream)
        radio_stream = getattr(session, '_radio_ptt_stream', None)
        if radio_stream is not None and self.radio_ptt is not None:
            self.radio_ptt.detach(radio_stream)
            session._radio_ptt_stream = None

        # Stop the looping load-test tone, if this was a /load leg.
        player = getattr(session, '_load_tone_player', None)
        if player is not None:
            try:
                player.stop()
            except Exception:
                pass
            session._load_tone_player = None

        # Stop the per-call tcpdump (if --dump was active).
        self._stop_pcap_capture(session)

        # Tear down any video windows associated with this session.
        # Close the FrameBufferVideoRenderer first (detaches the cb
        # so no more frames hit the queue) then close the window.
        for window, renderer in self.video_windows.pop(id(session), []):
            try:
                renderer.close()
            except Exception:
                pass
            try:
                window.close()
            except Exception:
                pass

        ui = UI()
        ui.status = None

        identity = str(session.remote_identity.uri)
        if session.remote_identity.display_name:
            identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
        if notification.data.end_reason == 'user request':
            show_notice('SIP session with %s ended by %s party' % (identity, notification.data.originator))
        else:
            show_notice('SIP session with %s ended due to error: %s' % (identity, notification.data.end_reason))
        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'
        show_notice('Session duration was %s' % duration_text)

        if session in self.connected_sessions:
            self.connected_sessions.remove(session)
        if session is self.active_session:
            if self.connected_sessions:
                self.active_session = self.connected_sessions[0]
                self.message_session_to = None
                self.active_session.unhold()
                self.ignore_local_unhold = True
                identity = str(self.active_session.remote_identity.uri)
                if self.active_session.remote_identity.display_name:
                    identity = '"%s" <%s>' % (self.active_session.remote_identity.display_name, identity)
            else:
                self.active_session = None

            self._update_prompt()

        if session is self.outgoing_session:
            self.outgoing_session = None

        # Move the mic/speakers to whichever load leg is active now.
        self._load_route_audio()

        on_hold_streams = [stream for stream in chain(*(session.streams for session in self.connected_sessions)) if stream.on_hold]
        if not on_hold_streams and self.hold_tone is not None:
            self.hold_tone.stop()
            self.hold_tone = None

        self._CH_sessions()
    
        if self.must_exit:
            self.stop()  

    def _NH_SIPSessionDidChangeHoldState(self, notification):
        session = notification.sender
        if notification.data.on_hold:
            if notification.data.originator == 'remote':
                if session is self.active_session:
                    show_notice('Remote party has put the session on hold')
                else:
                    identity = str(session.remote_identity.uri)
                    if session.remote_identity.display_name:
                        identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
                    show_notice('%s has put the session on hold' % identity)
            elif not self.ignore_local_hold:
                if session is self.active_session:
                    show_notice('Session was put on hold')
                else:
                    identity = str(session.remote_identity.uri)
                    if session.remote_identity.display_name:
                        identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
                    show_notice('Session with %s was put on hold' % identity)
            else:
                self.ignore_local_hold = False
            if self.hold_tone is None:
                self.hold_tone = WavePlayer(self.voice_audio_mixer, ResourcePath('sounds/hold_tone.wav').normalized, loop_count=0, pause_time=30, volume=50)
                self.voice_audio_bridge.add(self.hold_tone)
                self.hold_tone.start()
        else:
            if notification.data.originator == 'remote':
                if session is self.active_session:
                    show_notice('Remote party has taken the session out of hold')
                else:
                    identity = str(session.remote_identity.uri)
                    if session.remote_identity.display_name:
                        identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
                    show_notice('%s has taken the session out of hold' % identity)
            elif not self.ignore_local_unhold:
                if session is self.active_session:
                    show_notice('Session was taken out of hold')
                else:
                    identity = str(session.remote_identity.uri)
                    if session.remote_identity.display_name:
                        identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
                    show_notice('Session with %s was taken out of hold' % identity)
            else:
                self.ignore_local_unhold = False
            on_hold_streams = [stream for stream in chain(*(session.streams for session in self.connected_sessions)) if stream.on_hold]
            if not on_hold_streams and self.hold_tone is not None:
                self.hold_tone.stop()
                self.hold_tone = None

    def _NH_SIPSessionNewProposal(self, notification):
        self.sessions_with_proposals.add(notification.sender)
        if notification.data.originator == 'remote':
            proposal_handler = IncomingProposalHandler(notification.sender)
            proposal_handler.start()

    def _NH_SIPSessionDidRenegotiateStreams(self, notification):
        notification_center = NotificationCenter()
        for stream in notification.data.added_streams:
            notification_center.add_observer(self, sender=stream)

        for stream in notification.data.removed_streams:
            notification_center.remove_observer(self, sender=stream)

        session = notification.sender
        added_streams = ', '.join(stream.type for stream in notification.data.added_streams)
        removed_streams = ', '.join(stream.type for stream in notification.data.removed_streams)
        action = 'added' if added_streams else 'removed'
        message = '%s party %s %s' % (notification.data.originator.capitalize(), action, added_streams or removed_streams)
        if session is not self.active_session:
            identity = str(session.remote_identity.uri)
            if session.remote_identity.display_name:
                identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
            message = '%s in session with %s' % (message, identity)
        show_notice(message)
        self._update_prompt()

    def _NH_AudioStreamGotDTMF(self, notification):
        notification_center = NotificationCenter()
        digit = notification.data.digit
        filename = 'sounds/dtmf_%s_tone.wav' % {'*': 'star', '#': 'pound'}.get(digit, digit)
        wave_player = WavePlayer(self.voice_audio_mixer, ResourcePath(filename).normalized)
        notification_center.add_observer(self, sender=wave_player)
        self.voice_audio_bridge.add(wave_player)
        wave_player.start()
        show_notice('Got DMTF %s' % notification.data.digit)

    def _NH_RTPStreamZRTPVerifiedStateChanged(self, notification):
        self._update_prompt()

    def _NH_RTPStreamZRTPPeerNameChanged(self, notification):
        self._update_prompt()

    def _NH_RTPStreamDidEnableEncryption(self, notification):
        stream = notification.sender
        # Resolve the SIP Call-ID via the stream's session so audio /
        # video encryption activation lines are filterable alongside
        # the [zrtp] / [ui] lines on the Sylk Mobile side.
        cid = _sip_call_id(getattr(stream, 'session', None))
        show_notice("call_id=%s %s encryption activated using %s (%s)" % (cid, stream.type.title(), stream.encryption.type, stream.encryption.cipher.decode() if isinstance(stream.encryption.cipher, bytes) else stream.encryption.cipher))

        if stream.encryption.type == 'ZRTP' and stream.type == 'audio':
            peer_name = stream.encryption.zrtp.peer_name if stream.encryption.zrtp.peer_name else None
            sas = stream.encryption.zrtp.sas.decode() if isinstance(stream.encryption.zrtp.sas, bytes) else stream.encryption.zrtp.sas
            show_notice("call_id=%s ZRTP secret is %s" % (cid, sas))
            show_notice("call_id=%s ZRTP peer name is %s, use /zrtp_name command to change it" % (cid, (peer_name or 'not set')))
            show_notice("call_id=%s ZRTP peer is %s, use /zrtp_verified command to toggle it" % (cid, ('verified' if stream.encryption.zrtp.verified else 'not verified')))

        # Sylk-ZRTP-over-MESSAGE is now driven entirely by the SDK based on
        # the account's rtp.encryption setting and the X-Sylk-ZRTP capability
        # exchange. State transitions are surfaced via
        # SIPSessionSylkZRTPStateChanged (see the observer above).

        self._update_prompt()

    def _NH_RTPStreamDidChangeRTPParameters(self, notification):
        stream = notification.sender
        show_notice('Audio RTP parameters changed:')
        show_notice('Audio stream using %s codec at %sHz' % (stream.codec.capitalize(), stream.sample_rate))
        show_notice('Audio RTP endpoints %s:%d <-> %s:%d' % (stream.local_rtp_address, stream.local_rtp_port, stream.remote_rtp_address, stream.remote_rtp_port))
        if stream.encryption.active:
            show_notice('RTP audio stream is encrypted using %s (%s)\n' % (stream.encryption.type, stream.encryption.cipher))

    def _NH_AudioStreamDidStartRecordingAudio(self, notification):
        show_notice('Recording audio to %s' % notification.data.filename)

    def _NH_AudioStreamDidStopRecordingAudio(self, notification):
        show_notice('Stopped recording audio to %s' % notification.data.filename)

    def _NH_ChatStreamSMPVerificationDidStart(self, notification):
        show_notice('OTR verification started', bold=False)
        data = notification.data
        session = self.active_session
        stream = notification.sender
        if data.originator == 'remote':
            if data.question == self.smp_verification_question:
                try:
                    audio_stream = next(stream for stream in session.streams if stream.type=='audio' and stream.encryption.type=='ZRTP' and stream.encryption.active and stream.encryption.zrtp.verified)
                except StopIteration:
                    stream.encryption.smp_abort()
                else:
                    show_notice("OTR verification requested by remote and replied automatically using ZRTP SAS", bold=False)
                    stream.encryption.smp_answer(audio_stream.encryption.zrtp.sas.encode())
                    self.smp_verifified_using_zrtp = True
            else:
                show_notice("OTR verification requested by remote, reply using /otr_answer")
                show_notice('Question: %s' % data.question.decode())
                self._update_prompt()
        else:
            try:
                audio_stream = next(stream for stream in session.streams if stream.type=='audio' and stream.encryption.type=='ZRTP' and stream.encryption.active and stream.encryption.zrtp.verified)
            except StopIteration:
                pass
            else:
                stream.encryption.smp_verify(audio_stream.encryption.zrtp.sas.encode(), question=self.smp_verification_question)
                show_notice("OTR verification performed automatically using ZRTP SAS...", bold=False)
                self._update_prompt()

    def _NH_ChatStreamSMPVerificationDidNotStart(self, notification):
        pass

    def _NH_ChatStreamSMPVerificationDidEnd(self, notification):
        data = notification.data
        stream = notification.sender
        if data.status is SMPStatus.Success:
            show_notice("OTR verification %s" % ('succeeded' if data.same_secrets else 'failed: the secret is wrong'), bold=False)
            stream.encryption.verified = data.same_secrets
            self._update_prompt()
        elif data.status is SMPStatus.Interrupted:
            show_notice("OTR verification aborted: %s" % data.reason, bold=False)
        elif data.status is SMPStatus.ProtocolError:
            show_notice("OTR verification error: %s" % data.reason, bold=False)
            if data.reason == 'startup collision':
                self.smp_verification_tries -= 1
                self.smp_verification_delay *= 2
                if self.smp_verification_tries > 0:
                    # TODO verify later
                    pass

    def _NH_ChatStreamOTRError(self, notification):
        show_notice("Chat encryption error: %s", notification.data.error, bold=False)

    def _NH_ChatStreamOTRVerifiedStateChanged(self, notification):
        self._update_prompt()

    def _NH_ChatStreamOTREncryptionStateChanged(self, notification):
        data = notification.data
        stream = notification.sender
        if data.new_state is OTRState.Encrypted and not self.otr_enabled:
            # The remote party started OTR on the chat stream; the MSRP
            # stream negotiates that on its own, without asking us. The
            # account says we do not do OTR, so take it back down rather
            # than sit in a session the user cannot see, verify or end.
            show_notice("Chat encryption offered by remote party using OTR, refused: OTR is disabled for this account")
            stream.encryption.stop()
            self._update_prompt()
            return

        if data.new_state is OTRState.Encrypted:
            local_fingerprint = stream.encryption.key_fingerprint
            remote_fingerprint = stream.encryption.peer_fingerprint
            show_notice("Chat encryption activated using OTR protocol", bold=False)
            show_notice("OTR local fingerprint %s" % local_fingerprint, bold=False)
            show_notice("OTR remote fingerprint %s" % remote_fingerprint, bold=False)

            ui = UI()
            if stream.encryption.verified:
                ui.status = "OTR remote fingerprint has been verified"
            else:
                ui.status = "OTR remote fingerprint has not yet been verified"

            reactor.callLater(15, setattr, ui, 'status', None)

        elif data.new_state is OTRState.Finished:
            show_notice("Chat encryption deactivated", bold=False)
        elif data.new_state is OTRState.Plaintext:
            show_notice("Chat encryption deactivated", bold=False)

        self._update_prompt()

    def _NH_ChatStreamGotMessage(self, notification):
        if notification.data.message.content_type.lower() == radio_ptt.CONTENT_TYPE:
            self._handle_radio_ptt_message(notification.sender, notification.data.message)
            return
        if not notification.data.message.content_type.startswith("text/"):
            return
        message = notification.data.message
        remote_identity = message.sender.display_name or message.sender.uri
        content = message.content
        if isinstance(content, bytes):
            content = content.decode('utf-8', 'replace')
        if not content or not content.strip():
            # SylkServer sends empty/whitespace messages (e.g. on join);
            # lxml refuses to parse an empty document.
            return
        if message.content_type == 'text/html':
            # document_fromstring always yields <html><body>, which
            # fromstring does not for a bare fragment (no .body then)
            try:
                doc = html.document_fromstring(content)
            except Exception:
                text = content
            else:
                body = doc.body
                if body.text is not None:
                    body.text = body.text.lstrip('\n')
                for br in body.xpath('.//br'):
                    br.tail = '\n' + (br.tail or '')
                text = body.text_content()
        else:
            # text/plain is not markup: parsing it as HTML drops anything
            # that looks like a tag and fails on a plain string
            text = content
        head = RichText('%s> ' % remote_identity, foreground='blue')
        ui = UI()
        ui.writelines([head + line for line in text.splitlines()])

    def _handle_radio_ptt_message(self, stream, message):
        session = stream.session
        if self.radio_ptt is None or getattr(session, '_radio_ptt_stream', None) is not stream:
            show_notice('Ignoring radio PTT message: radio PTT is not configured for this session', bold=False)
            return
        # Only the conference focus may key the radio.
        def _aor(uri):
            def _s(x):
                return (x.decode() if isinstance(x, bytes) else (x or '')).lower()
            return '%s@%s' % (_s(uri.user), _s(uri.host))
        sender = message.sender.uri if message.sender is not None else None
        if sender is None or _aor(sender) != _aor(session.remote_identity.uri):
            show_notice('Ignoring radio PTT message from %s: not from the conference focus' % sender, bold=False)
            return
        self.radio_ptt.handle_command(stream, message.content)

    def _NH_DefaultAudioDeviceDidChange(self, notification):
        SIPApplication._NH_DefaultAudioDeviceDidChange(self, notification)
        if notification.data.changed_input and self.voice_audio_mixer.input_device=='system_default':
            show_notice('Switched default input device to: %s' % self.voice_audio_mixer.real_input_device)
        if notification.data.changed_output and self.voice_audio_mixer.output_device=='system_default':
            show_notice('Switched default output device to: %s' % self.voice_audio_mixer.real_output_device)
        if notification.data.changed_output and self.alert_audio_mixer.output_device=='system_default':
            show_notice('Switched alert device to: %s' % self.alert_audio_mixer.real_output_device)

    def _NH_AudioDevicesDidChange(self, notification):
        engine = Engine()
        settings = SIPSimpleSettings()

        old_devices = set(notification.data.old_devices)
        new_devices = set(notification.data.new_devices)
        added_devices = new_devices - old_devices
        removed_devices = old_devices - new_devices
        changed_input_device = self.voice_audio_mixer.real_input_device in removed_devices
        changed_output_device = self.voice_audio_mixer.real_output_device in removed_devices
        changed_alert_device = self.alert_audio_mixer.real_output_device in removed_devices

        SIPApplication._NH_AudioDevicesDidChange(self, notification)

        if added_devices:
            show_notice('Added audio device(s): %s' % ', '.join(sorted(added_devices)))
        if removed_devices:
            show_notice('Removed audio device(s): %s' % ', '.join(sorted(removed_devices)))
        if changed_input_device:
            show_notice('Input device has been switched to: %s' % self.voice_audio_mixer.real_input_device)
        if changed_output_device:
            show_notice('Output device has been switched to: %s' % self.voice_audio_mixer.real_output_device)
        if changed_alert_device:
            show_notice('Alert device has been switched to: %s' % self.alert_audio_mixer.real_output_device)

        try:
            device = str(list(added_devices)[0])
        except IndexError:
            pass
        else:
            in_devices = list(set(engine.input_devices))
            out_devices = list(set(engine.output_devices))

            new_input_device = self.voice_audio_mixer.input_device
            new_output_device = self.voice_audio_mixer.output_device
            if device in in_devices:
                settings.audio.input_device = str(device)
                new_input_device = device
                        
            if device in out_devices:
                settings.audio.output_device = str(device)
                new_output_device = device

            settings.save()
            
            tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0

            try:
                self.voice_audio_mixer.set_sound_devices(new_input_device, new_output_device, tail_length)
            except SIPCoreError as e:
                pass

    def _NH_WavePlayerDidEnd(self, notification):
        notification_center = NotificationCenter()
        notification_center.remove_observer(self, sender=notification.sender)

    def _NH_MediaStreamDidNotInitialize(self, notification):
        # A stream that fails to initialize is dropped from the answer (port 0)
        # while the others go on, so say which one and why.
        stream = notification.sender
        session = getattr(stream, 'session', None)
        remote = session.remote_identity.uri if session is not None and session.remote_identity is not None else 'unknown'
        reason = getattr(notification.data, 'reason', None) or 'unknown reason'
        transport = getattr(notification.data, 'transport', None)
        show_notice(_ts() + 'Could not set up %s with %s%s: %s' % (getattr(stream, 'type', 'media'), remote, ' (%s)' % transport if transport else '', reason))
        if self.must_exit:
            self.stop()

    def _NH_SIPSessionTransferGotProgress(self, notification):
        show_notice('Session transfer progress: %s (%s)' % (notification.data.reason, notification.data.code))

    def _NH_SIPSessionTransferDidFail(self, notification):
        show_notice('Session transfer failed: %s (%s)' % (notification.data.reason, notification.data.code))
        ui = UI()
        ui.status = None

    def _NH_SIPSessionGotConferenceInfo(self, notification):
        info = notification.data.conference_info
        self._send_pending_invites(notification.sender, info)
        self._check_hangup_on_leave(notification.sender, info)
        # Track radio PTT endpoints on every leg (used by /ptt), report
        # changes only for the active one.
        radios = self._radio_ptt_from_conference_info(info)
        previous = getattr(notification.sender, '_radio_ptt_endpoints', {}) or {}
        notification.sender._radio_ptt_endpoints = radios
        # Every leg receives its own conference NOTIFY, so during a load test
        # this would print once per leg. Only report the conference roster for
        # the call you're actually on (the active session).
        if notification.sender is not self.active_session:
            return
        for entity, radio in radios.items():
            old = previous.get(entity)
            if old is None or (old['state'], old['by']) != (radio['state'], radio['by']):
                show_notice(self._format_radio_ptt(radio))

        try:
            state = info.conference_state
            user_count = getattr(state.user_count, 'value', state.user_count) if state is not None else None
        except Exception:
            user_count = None
        try:
            n_users = len(list(info.users))
        except Exception:
            n_users = '?'
        show_notice('%s conference-info NOTIFY v%s participants=%s users=%s call-id %s' % (
            datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], info.version, user_count, n_users, _sip_call_id(notification.sender)), bold=False)

        def _bare(entity):
            return entity.split(':', 1)[1] if entity and ':' in entity else (entity or '')

        lines = ['Conference users:']
        for user in info.users:
            aor = _bare(user.entity)
            # Each user (AoR) can be joined from several devices; every
            # endpoint carries its own instance id (its Contact URI).
            try:
                endpoints = list(user)
            except TypeError:
                endpoints = []
            instances = []
            for ep in endpoints:
                instance = _bare(getattr(ep, 'entity', None))
                if not instance:
                    continue
                radio = radios.get(ep.entity)
                if radio is not None:
                    instance += ' [radio %s]' % radio['state']
                instances.append(instance)
            if instances:
                lines.append('  %s (%d device%s): %s' % (aor, len(instances), '' if len(instances) == 1 else 's', ', '.join(instances)))
            else:
                lines.append('  %s' % aor)
        show_notice(lines)
        if info.conference_description.resources:
            for file in info.conference_description.resources.files:
                show_notice('Conference shared file: %s (%s bytes)' % (file.name, file.size))

    @staticmethod
    def _radio_ptt_from_conference_info(info):
        radios = {}
        for user in info.users:
            aor = user.entity.split(':', 1)[1] if ':' in user.entity else user.entity
            try:
                endpoints = list(user)
            except TypeError:
                continue
            for endpoint in endpoints:
                element = getattr(endpoint, 'radio_ptt', None)
                if element is None:
                    continue
                def _int(value):
                    try:
                        return int(value)
                    except (TypeError, ValueError):
                        return None
                radios[endpoint.entity] = dict(aor=aor, entity=endpoint.entity, state=element.state, by=element.by,
                                               elapsed=_int(element.elapsed), timeout=_int(element.timeout),
                                               reason=element.reason)
        return radios

    @staticmethod
    def _format_radio_ptt(radio):
        text = 'Radio %s: %s' % (radio['aor'], radio['state'])
        if radio['state'] == 'transmitting':
            text += ' — speak now' if radio['by'] != 'local' else ' (keyed locally at the radio)'
        if radio['by'] and radio['by'] != 'local':
            text += ' by %s' % radio['by']
        if radio['state'] == 'transmitting' and radio['timeout'] is not None and radio['elapsed'] is not None:
            text += ' (%ds left)' % max(radio['timeout'] - radio['elapsed'], 0)
        if radio['reason']:
            text += ' (%s)' % radio['reason']
        return text

    def _CH_ptt(self, action=None, target=None):
        session = self.active_session
        if session is None:
            show_notice('There is no active session')
            return
        if action not in (None, 'press', 'release', 'status'):
            if target is not None:
                show_notice('Usage: /ptt [press|release|status] [participant]')
                return
            action, target = None, action  # /ptt <participant> toggles that radio
        radios = getattr(session, '_radio_ptt_endpoints', None) or {}
        if action is None:
            # /ptt without an action toggles: release while a radio is
            # keying or on air (its timer still running), press otherwise.
            # Scoped to the named participant when one is given.
            scope = radios.values()
            if target is not None:
                bare = re.sub('^sips?:', '', target)
                scope = [r for r in scope if bare in (r['aor'], re.sub('^sips?:', '', r['entity']))]
            busy = any(r['state'] in ('keying', 'transmitting') for r in scope)
            action = 'release' if busy else 'press'
        if action == 'status' or not radios:
            if not radios:
                show_notice('No participant in this conference has a radio')
            for radio in radios.values():
                show_notice(self._format_radio_ptt(radio))
            return
        if target is None:
            # Conference wide: press keys every radio, release stops those
            # keying or on air (all of them if none is).
            targets = list(radios.values())
            if action == 'release':
                active = [r for r in targets if r['state'] in ('keying', 'transmitting')]
                targets = active or targets
        else:
            target = re.sub('^sips?:', '', target)
            targets = [r for r in radios.values() if target in (r['aor'], re.sub('^sips?:', '', r['entity']))]
            if not targets:
                show_notice('No radio matches %s; radios: %s' % (target, ', '.join(sorted(r['aor'] for r in radios.values()))))
                return
        try:
            chat_stream = next(stream for stream in session.streams if stream.type == 'chat')
        except StopIteration:
            show_notice('The active session has no chat stream')
            return
        last_ids = session.__dict__.setdefault('_radio_ptt_last_ids', {})
        for radio in targets:
            if action == 'press':
                command_id = last_ids[radio['entity']] = radio_ptt.new_command_id()
            else:
                command_id = last_ids.get(radio['entity']) or radio_ptt.new_command_id()
            payload = {'action': action, 'id': command_id, 'endpoint': radio['entity']}
            chat_stream.send_message(json.dumps(payload), content_type=radio_ptt.CONTENT_TYPE)
            show_notice('%s radio PTT %s id=%s -> %s' % (datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], action, command_id, radio['aor']), bold=False)

    REINVITE_INTERVAL = 60

    def _send_pending_invites(self, session, info):
        present = set()
        for user in info.users:
            entity = user.entity or ''
            aor = entity.split(':', 1)[1] if ':' in entity else entity
            present.add(aor.lower())
        # the roster as last seen, for the periodic re-invite
        session._conference_present = present
        if not getattr(session, '_invite_pending', False):
            return
        session._invite_pending = False
        if session.state != 'connected':
            return
        for uri in _split_users(self.options.invite):
            aor = re.sub('^sips?:', '', uri).lower()
            if aor in present:
                show_notice(_ts() + '%s is already in the conference' % aor, bold=False)
                continue
            if aor in self.invite_refused:
                show_notice(_ts() + '%s declined to join before, not inviting' % aor, bold=False)
                continue
            self._invite_participant(session, uri)
        # -R: keep inviting whoever is not in the conference
        if self.options.auto_reconnect:
            reactor.callLater(self.REINVITE_INTERVAL, self._reinvite_missing, session)

    def _invite_participant(self, session, uri):
        if re.match('^(sip:|sips:)', uri) is None:
            uri = 'sip:%s' % uri
        try:
            session.conference.add_participant(SIPURI.parse(uri))
        except Exception as e:
            show_notice(_ts() + 'Could not invite %s: %s' % (uri, e))
        else:
            show_notice(_ts() + 'Inviting %s to the conference' % uri, bold=False)

    def _reinvite_missing(self, session):
        if self.stopping or session.state != 'connected':
            return
        present = getattr(session, '_conference_present', set())
        for uri in _split_users(self.options.invite):
            aor = re.sub('^sips?:', '', uri).lower()
            if aor not in present and aor not in self.invite_refused:
                self._invite_participant(session, uri)
        reactor.callLater(self.REINVITE_INTERVAL, self._reinvite_missing, session)

    def _check_hangup_on_leave(self, session, info):
        # --hangup-on-leave: once at least one watched user has been seen in
        # the conference roster, hang up when none of them is left in it.
        watched = _split_users(self.options.hangup_on_leave)
        if not watched or not getattr(session, 'remote_focus', False):
            return
        if session.state != 'connected':
            return
        present = set()
        for user in info.users:
            entity = user.entity or ''
            aor = entity.split(':', 1)[1] if ':' in entity else entity
            present.add(aor.lower())

        def _matches(watch, aor):
            return aor == watch if '@' in watch else aor.split('@', 1)[0] == watch

        seen = getattr(session, '_hangup_on_leave_seen', None)
        if seen is None:
            seen = session._hangup_on_leave_seen = set()
        still_present = False
        for watch in watched:
            watch = re.sub('^sips?:', '', watch).lower()
            if any(_matches(watch, aor) for aor in present):
                seen.add(watch)
                still_present = True
        if seen and not still_present:
            show_notice(_ts() + '%s left the conference, hanging up' % ', '.join(sorted(seen)))
            session.end()

    def _NH_RTPStreamICENegotiationDidSucceed(self, notification):
        show_notice(" ")
        show_notice("ICE negotiation succeeded in %s seconds" % notification.data.duration)
        if self.rtp_statistics:
            show_notice(" ")
            show_notice("Local ICE candidates:")
            for candidate in notification.data.local_candidates:
                show_notice(str(candidate))
            show_notice(" ")
            show_notice("Remote ICE candidates:")
            for candidate in notification.data.remote_candidates:
                show_notice(str(candidate))
            show_notice(" ")
            show_notice("ICE connectivity valid pairs:")
            for check in notification.data.valid_pairs:
                show_notice(str(check))
            show_notice(" ")

    def _NH_RTPStreamICENegotiationDidFail(self, notification):
        show_notice("\n")
        show_notice("ICE negotiation failed: %s" % notification.data.reason.decode())

    # command handlers
    #

    def _CH_call(self, target, video_option=None):
        if video_option and video_option != '+video':
            raise TypeError()

        if self.outgoing_session is not None:
            show_notice('Please cancel any outgoing sessions before starting a new one')
            return
        call_initializer = OutgoingCallInitializer(self.account, target, audio=True, video=video_option=='+video')
        call_initializer.start()

    def _CH_audio(self, target, chat_option=None):
        if chat_option and chat_option != '+chat':
            raise TypeError()
        if self.outgoing_session is not None:
            show_notice('Please cancel any outgoing sessions before starting a new one')
            return
        call_initializer = OutgoingCallInitializer(self.account, target, audio=True, chat=chat_option=='+chat')
        call_initializer.start()

    def _load_noise_file(self):
        # Lazily synthesise 2 s of mono white noise (16 kHz / 16-bit) that the
        # load legs loop into the mixer. Neutral hiss, moderate amplitude so it
        # isn't piercing, but real non-zero signal so the audio-level sampler
        # has something to measure on every stream. Cached after first use.
        path = getattr(self, '_load_noise_path', None)
        if path and os.path.exists(path):
            return path
        import wave, struct, random, tempfile
        path = os.path.join(tempfile.gettempdir(), 'sip-session-load-noise.wav')
        rate, duration, amp = 16000, 2.0, 7000
        nframes = int(rate * duration)
        rnd = random.Random(1)
        frames = bytearray()
        for _ in range(nframes):
            frames += struct.pack('<h', rnd.randint(-amp, amp))
        with wave.open(path, 'wb') as w:
            w.setnchannels(1)
            w.setsampwidth(2)
            w.setframerate(rate)
            w.writeframes(bytes(frames))
        self._load_noise_path = path
        return path

    def _load_route_audio(self):
        # Wire your microphone + speakers (the local audio device) to the ACTIVE
        # load leg only. Every other load leg stays a fully active RTP
        # participant - so the server keeps mixing and audio-level-sampling all
        # of them - but is disconnected from your mic/speakers, so you neither
        # pump your mic into 30 legs at once nor hear the conference mixed back
        # 30 times over. Talk on the active leg and the real participants hear
        # you; /next moves the mic to another leg.
        if not getattr(self, '_load_active', False):
            return
        for session in list(self.connected_sessions):
            if not getattr(session, '_load', False):
                continue
            self._load_set_device(session, session is self.active_session)

    def _load_set_device(self, session, connected):
        audio_stream = next((s for s in (session.streams or []) if s.type == 'audio'), None)
        if audio_stream is None:
            return
        device = getattr(audio_stream, 'device', None)
        if device is None:
            return
        # Streams come up with the device already in their bridge.
        cur = getattr(session, '_load_device_connected', True)
        if connected and not cur:
            try:
                audio_stream.bridge.add(device)
            except Exception:
                pass
            session._load_device_connected = True
        elif not connected and cur:
            try:
                audio_stream.bridge.remove(device)
            except Exception:
                pass
            session._load_device_connected = False

    def _load_start_audio(self, session):
        # Optional: inject audible audio (a sound file, or 'noise') into the
        # NON-active load legs. By default nothing is injected - the legs are
        # silent load. The active leg is left clear for your microphone (see
        # _load_route_audio), so we never inject into it.
        sound = getattr(self, '_load_soundfile', None)
        if sound and sound not in ('silence', 'blank', 'mute') and session is not self.active_session:
            try:
                audio_stream = next((s for s in (session.streams or []) if s.type == 'audio'), None)
                if audio_stream is not None:
                    if sound in ('noise', 'whitenoise', 'white-noise'):
                        path = self._load_noise_file()
                    else:
                        path = sound if os.path.isabs(sound) else ResourcePath(sound).normalized
                        if not os.path.exists(path):
                            show_notice('Load test: sound file not found: %s' % path)
                            path = None
                    if path:
                        player = WavePlayer(audio_stream.mixer, path, loop_count=0, pause_time=0, volume=15)
                        audio_stream.bridge.add(player)
                        player.start()
                        session._load_tone_player = player
            except Exception as e:
                show_notice('Load test: could not start sound for a call: %s' % e)
        self._load_route_audio()

    def _CH_load(self, target, capacity='30', timeout='120', soundfile=None):
        # /load {room} [capacity] [timeout] [soundfile]
        #     Ramp [capacity] audio calls to {room} at 1/sec, hold [timeout]s
        #     (0 = hold until /load stop), then hang up all. Legs stay active
        #     (never on hold); the active leg carries your mic, the rest are
        #     silent load. Pass [soundfile] (or 'noise') to inject audio into
        #     the non-active legs.
        # /load add {n}      add n more legs to the running test's room
        # /load remove {n}   hang up n of the running test's legs
        # /load stop         hang up everything and end the test
        #
        # add/remove always act on the load-test room (self._load_target), not
        # on whatever call happens to be active, so you can adjust concurrency
        # live and watch the server's memory react.
        if target == 'stop':
            self._load_teardown(manual=True)
            return
        if target == 'add':
            self._load_add(capacity)
            return
        if target == 'remove':
            self._load_remove(capacity)
            return
        try:
            capacity = int(capacity)
            timeout = int(timeout)
        except (TypeError, ValueError):
            raise TypeError()
        if capacity < 1:
            show_notice('Load test: capacity must be >= 1')
            return
        if getattr(self, '_load_active', False):
            show_notice('Load test already running on %s; use /load add {n} | /load remove {n} | /load stop' % getattr(self, '_load_target', '?'))
            return
        self._load_active = True
        self._load_calls = []
        self._load_soundfile = soundfile
        self._load_target = target
        self._load_count = capacity
        hold = 'hold until /load stop' if timeout == 0 else 'hold %ds then hang up all' % timeout
        show_notice('Load test: ramping %d call(s) to %s at 1/sec, %s (audio: %s)' % (capacity, target, hold, soundfile or 'silence'))
        # Schedule one new call per second up to capacity...
        for i in range(capacity):
            self._load_calls.append(reactor.callLater(i, self._load_spawn_one, target, i + 1, capacity))
        # ...then tear everything down once ramp-up + hold time has elapsed
        # (unless timeout is 0, meaning run until /load stop).
        if timeout > 0:
            self._load_calls.append(reactor.callLater(capacity + timeout, self._load_teardown))

    def _load_add(self, count):
        if not getattr(self, '_load_active', False) or not getattr(self, '_load_target', None):
            show_notice('No load test is running; start one with /load {room} {capacity}')
            return
        try:
            count = int(count)
        except (TypeError, ValueError):
            raise TypeError()
        if count < 1:
            show_notice('Load test: add count must be >= 1')
            return
        target = self._load_target
        base = getattr(self, '_load_count', 0)
        show_notice('Load test: adding %d call(s) to %s at 1/sec' % (count, target))
        for i in range(count):
            self._load_calls.append(reactor.callLater(i, self._load_spawn_one, target, base + i + 1, base + count))
        self._load_count = base + count

    def _load_remove(self, count):
        if not getattr(self, '_load_active', False):
            show_notice('No load test is running')
            return
        try:
            count = int(count)
        except (TypeError, ValueError):
            raise TypeError()
        if count < 1:
            show_notice('Load test: remove count must be >= 1')
            return
        # Only the load-test legs are eligible; prefer non-active ones so your
        # voice leg survives until last.
        load_sessions = [s for s in self.connected_sessions if getattr(s, '_load', False)]
        load_sessions.sort(key=lambda s: s is self.active_session)
        to_remove = load_sessions[:count]
        if not to_remove:
            show_notice('Load test: no load legs to remove')
            return
        show_notice('Load test: removing %d call(s)' % len(to_remove))
        for session in to_remove:
            try:
                session.end()
            except Exception:
                pass

    def _load_spawn_one(self, target, n, capacity):
        if not getattr(self, '_load_active', False):
            return
        show_notice('Load test: starting call %d/%d to %s' % (n, capacity, target))
        try:
            OutgoingCallInitializer(self.account, target, audio=True, load=True).start()
        except Exception as e:
            show_notice('Load test: call %d failed to start: %s' % (n, e))

    def _load_teardown(self, manual=False):
        if not getattr(self, '_load_active', False):
            if manual:
                show_notice('No load test is running')
            return
        self._load_active = False
        # Cancel any still-pending ramp-up / teardown timers (relevant when
        # the user stops the test mid ramp-up).
        for call in getattr(self, '_load_calls', []):
            try:
                if call.active():
                    call.cancel()
            except Exception:
                pass
        self._load_calls = []
        # Only hang up the load-test legs, never a manual call you may have up.
        sessions = [s for s in self.connected_sessions if getattr(s, '_load', False)]
        show_notice('Load test: %s - hanging up %d call(s)' % ('stopped' if manual else 'timeout reached', len(sessions)))
        for session in sessions:
            try:
                session.end()
            except Exception:
                pass
        if self.outgoing_session is not None and getattr(self.outgoing_session, '_load', False):
            try:
                self.outgoing_session.end()
            except Exception:
                pass
        self._load_target = None
        self._load_count = 0

    def _CH_video(self, target=None, chat_option=None):
        # In-call window control: `/video open` and `/video close`
        # spawn / tear down the on-screen video display for the
        # currently active session.  Falls through to the original
        # "start a new outgoing video call" behaviour when called
        # with a SIP URI.
        if target in ('open', 'close'):
            session = self.active_session
            if session is None:
                show_notice('No active session.  Place or accept a call first.')
                return
            video_streams = [s for s in (session.streams or []) if s.type == 'video']
            if not video_streams:
                show_notice('No video stream on the active session.')
                return
            session_key = id(session)
            if target == 'open':
                # Drop dead entries first — the user may have closed
                # the previous window with the mouse, in which case
                # the subprocess is already gone but the bookkeeping
                # still has the stale (window, renderer) pair.
                existing = self.video_windows.get(session_key, [])
                live = [(w, r) for (w, r) in existing if w.is_alive()]
                for window, renderer in existing:
                    if window.is_alive():
                        continue
                    try:
                        renderer.close()
                    except Exception:
                        pass
                    try:
                        window.close()
                    except Exception:
                        pass
                if live:
                    self.video_windows[session_key] = live
                    show_notice('Video window already open.')
                    return
                # All previous windows are dead (or none existed); discard
                # the stale entry and spawn fresh ones below.
                self.video_windows.pop(session_key, None)
                identity = str(session.remote_identity.uri)
                opened = 0
                for stream in video_streams:
                    try:
                        window = VideoWindow(title='Video — %s' % identity)
                        renderer = FrameBufferVideoRenderer(window.frame_handler)
                        renderer.producer = stream.producer
                        self.video_windows.setdefault(session_key, []).append(
                            (window, renderer)
                        )
                        window.start()
                        opened += 1
                    except Exception as exc:
                        show_notice('Could not open video window: %s' % exc)
                if opened:
                    show_notice('Opened %d video window(s) for %s' % (opened, identity))
            else:  # 'close'
                entries = self.video_windows.pop(session_key, [])
                if not entries:
                    show_notice('No video window open for the active session.')
                    return
                for window, renderer in entries:
                    try:
                        renderer.close()
                    except Exception:
                        pass
                    try:
                        window.close()
                    except Exception:
                        pass
                show_notice('Closed %d video window(s).' % len(entries))
            return

        # Otherwise: original behaviour — start an outgoing video call.
        if chat_option and chat_option != '+chat':
            raise TypeError()
        if self.outgoing_session is not None:
            show_notice('Please cancel any outgoing sessions before starting a new one')
            return
        call_initializer = OutgoingCallInitializer(self.account, target, audio=True, video=True, chat=chat_option=='+chat')
        call_initializer.start()

    def _CH_m(self, target=None):
        self._CH_message(target)

    def _CH_message(self, target=None):
        if not target:
            show_notice('Usage: /message user@domain')
                
        route = None
                
        if isinstance(self.account, BonjourAccount):
            try:
                idx = int(target)
                neighbour = list(self.neighbours.values())[idx-1]
                show_notice('Using Bonjour neighbour "%s (%s)" <%s>' % (neighbour.display_name, neighbour.host, neighbour.uri))
                target = '%s@%s' % (neighbour.uri.user, neighbour.uri.host)
                route = Route(address=neighbour.uri.host, port=neighbour.uri.port, transport=neighbour.uri.transport)
            except (IndexError, TypeError):
                i = 1
                lines = ['Bonjour neighbours:']
                for key, neighbour in self.neighbours.items():
                    lines.append(' "%s (%s)" <%s> (%i)' % (neighbour.display_name, neighbour.host, neighbour.uri, i))
                    i = i + 1
                show_notice(lines)
 
        try:
            SIPURI.parse('sip:%s' % target)
            new_session = self.message_session_to != target
            if new_session:
                message_session = self.message_session(target, route=route)
            self.message_session_to = target
            self._update_prompt()
            # Ask the server for the peer's public key on first /m to that target.
            if new_session and not isinstance(self.account, BonjourAccount):
                lookup_target = target if '@' in target else '%s@%s' % (target, self.account.id.domain)
                self._lookup_public_key(lookup_target)
        except SIPCoreError:
            show_notice('Invalid SIP URI %s' % target)

    def _CH_chat(self, target, audio_option=None):
        if audio_option and audio_option != '+audio':
            raise TypeError()
        if self.outgoing_session is not None:
            show_notice('Please cancel any outgoing sessions before starting a new one')
            return
        call_initializer = OutgoingCallInitializer(self.account, target, audio=audio_option=='+audio', chat=True)
        call_initializer.start()

    def _NH_SIPConferenceGotAddParticipantProgress(self, notification):
        show_notice(_ts() + 'Invitation to %s: %s %s' % (notification.data.participant, notification.data.code, notification.data.reason), bold=False)

    # Final answers of an invited party that mean "I do not want to join" (the
    # invitee pressed reject or decline); anything else (no answer, offline,
    # busy with another call) is worth trying again.
    INVITE_REFUSAL_CODES = (600, 603)

    def _NH_SIPReferralGotNotify(self, notification):
        # The progress of an invitation, as the conference server reports it in
        # the NOTIFYs of the REFER (a sipfrag: "SIP/2.0 <code> <reason>"). The
        # SDK only reports the provisional and 2xx ones, so the final answer
        # of the invitee is taken from here.
        data = notification.data
        if getattr(data, 'event', None) != 'refer' or not getattr(data, 'body', None):
            return
        body = data.body.decode(errors='replace') if isinstance(data.body, bytes) else str(data.body)
        match = re.match(r'\s*SIP/2\.0\s+(\d{3})\s*([^\r\n]*)', body)
        if match is None:
            return
        code, reason = int(match.group(1)), match.group(2).strip()
        refer_to = getattr(notification.sender, 'refer_to_header', None)
        if refer_to is None or str(refer_to.parameters.get('method', 'INVITE')).upper() != 'INVITE':
            return
        participant = re.split('[;?>]', re.sub('^<?sips?:', '', str(refer_to.uri)), maxsplit=1)[0].lower()
        if code < 300:
            return
        if code in self.INVITE_REFUSAL_CODES:
            self.invite_refused.add(participant)
            show_notice(_ts() + '%s declined to join: %d %s (will not be invited again)' % (participant, code, reason))
        else:
            show_notice(_ts() + 'Invitation to %s ended: %d %s' % (participant, code, reason), bold=False)

    def _NH_SIPConferenceDidAddParticipant(self, notification):
        # the REFER subscription ended; the invitee's answer, when it was not
        # a success, is reported from its NOTIFYs (_NH_SIPReferralGotNotify)
        show_notice(_ts() + 'Invitation to %s finished' % notification.data.participant, bold=False)

    def _NH_SIPConferenceDidNotAddParticipant(self, notification):
        show_notice(_ts() + 'Invitation to %s failed: %s %s' % (notification.data.participant, notification.data.code, notification.data.reason))

    def _NH_SIPIncomingReferralGotRefer(self, notification):
        # --join-when-invited: a conference focus invites us with an
        # out-of-dialog REFER whose Refer-To is the room URI.
        allowed = getattr(self.options, 'join_when_invited', None)
        if allowed is None:
            return
        referral = notification.sender
        refer_to = notification.data.refer_to
        from_header = notification.data.headers.get('From')

        def _aor(value):
            value = str(value or '').strip().strip('<>')
            value = re.sub('^sips?:', '', value)
            return re.split('[;?>]', value, maxsplit=1)[0].lower()

        room = _aor(getattr(refer_to, 'uri', refer_to))
        inviter = _aor(getattr(from_header, 'uri', ''))
        if not room or '@' not in room:
            show_notice(_ts() + 'Ignoring invitation with invalid Refer-To: %s' % refer_to)
            try:
                referral.reject(400)
            except Exception:
                pass
            return

        if allowed:
            def _matches(watch, aor):
                watch = re.sub('^sips?:', '', watch).lower()
                return aor == watch if '@' in watch else aor.split('@', 1)[0] == watch
            if not any(_matches(w, room) or _matches(w, inviter) for w in allowed):
                show_notice(_ts() + 'Rejected invitation from %s to %s (not in --join-when-invited)' % (inviter, room))
                try:
                    referral.reject(403)
                except Exception:
                    pass
                return

        for session in self.connected_sessions:
            if _aor(session.remote_identity.uri) == room:
                show_notice(_ts() + 'Invited by %s to %s, already joined' % (inviter, room), bold=False)
                try:
                    referral.accept()
                    referral.end(200)
                except Exception:
                    pass
                return

        if self.outgoing_session is not None:
            show_notice(_ts() + 'Invited by %s to %s, but an outgoing session is in progress' % (inviter, room))
            try:
                referral.reject(486)
            except Exception:
                pass
            return

        show_notice(_ts() + 'Invited by %s to %s, joining' % (inviter, room))
        try:
            referral.accept()
        except Exception as e:
            show_notice(_ts() + 'Could not accept invitation: %s' % e)
            return
        call_initializer = OutgoingCallInitializer(
            self.account, room,
            audio=True,
            chat=bool(getattr(self.options, 'with_chat', False)),
            video=bool(getattr(self.options, 'with_video', False)),
            auto_reconnect=self.options.auto_reconnect,
        )
        call_initializer.start()
        try:
            referral.end(200)
        except Exception:
            pass

    def _NH_SessionMustReconnect(self, notification):
        if self.stopping:
            return
        show_notice('Reconnecting session to %s\n' % notification.data.target)
        self._CH_conf(notification.data.target)

    def _CH_conf(self, target):
        if self.outgoing_session is not None:
            show_notice('Please cancel any outgoing sessions before starting a new one')
            return
        if '@' not in target:
            server_address = self.account.conference.server_address
            if not server_address:
                if 'sip2sip' in self.account.id:
                    server_address = 'conference.sip2sip.info'
                elif 'sylk.link' in self.account.id:
                    server_address = 'conference.sip2sip.info'

                if server_address:
                    target = '%s@%s' % (target, server_address)
                else:
                    show_notice('conference server_address is not defined')
                    return

        call_initializer = OutgoingCallInitializer(self.account, target, audio=True, chat=True, auto_reconnect=True)
        call_initializer.start()

    def send_outgoing_file(self, target, filepath):
        if '~' in filepath:
            filepath = os.path.expanduser(filepath)
        filepath = os.path.abspath(filepath)
        if (os.path.exists(filepath)):
            transfer_handler = OutgoingTransferHandler(self.account, target, filepath)
            transfer_handler.start()
        else:
            show_notice('File %s does not exist' % filepath)


    def _CH_send(self, target, filepath):
        self.send_outgoing_file(target, filepath)

    def _CH_next(self):
        if len(self.connected_sessions) > 1:
            self.active_session.hold()
            self.active_session = self.connected_sessions[(self.connected_sessions.index(self.active_session)+1) % len(self.connected_sessions)]
            self.message_session_to = None
            self.active_session.unhold()
            self.ignore_local_unhold = True
            identity = str(self.active_session.remote_identity.uri)
            if self.active_session.remote_identity.display_name:
                identity = '"%s" <%s>' % (self.active_session.remote_identity.display_name, identity)
            show_notice('Active SIP session: "%s" (%d/%d)' % (identity, self.connected_sessions.index(self.active_session)+1, len(self.connected_sessions)))
            self._update_prompt()
            self._load_route_audio()

    def _CH_prev(self):
        if len(self.connected_sessions) > 1:
            self.active_session.hold()
            self.active_session = self.connected_sessions[self.connected_sessions.index(self.active_session)-1]
            self.message_session_to = None
            self.active_session.unhold()
            self.ignore_local_unhold = True
            identity = str(self.active_session.remote_identity.uri)
            if self.active_session.remote_identity.display_name:
                identity = '"%s" <%s>' % (self.active_session.remote_identity.display_name, identity)
            show_notice('Active SIP session: "%s" (%d/%d)' % (identity, self.connected_sessions.index(self.active_session)+1, len(self.connected_sessions)))
            self._update_prompt()
            self._load_route_audio()

    def _CH_sessions(self):
        if self.connected_sessions:
            lines = ['Connected sessions:']
            for session in self.connected_sessions:
                identity = str(session.remote_identity.uri)
                if session.remote_identity.display_name:
                    identity = '"%s" <%s>' % (session.remote_identity.display_name, identity)
                if any(getattr(stream, 'on_hold', False) for stream in (session.streams or [])):
                    state = 'on hold'
                elif session is self.active_session:
                    state = 'active'
                else:
                    state = 'connected'
                lines.append('  SIP session with %s (%d/%d) - %s' % (identity, self.connected_sessions.index(session)+1, len(self.connected_sessions), state))
            if len(self.connected_sessions) > 1:
                lines.append('Use the /next and /prev commands to switch the active session')
            show_notice(lines)
        else:
            show_notice('There are no connected sessions')

    def _CH_n(self):
        self._CH_neighbours()

    def _CH_neighbours(self):
        lines = ['Bonjour neighbours:']
        for key, neighbour in self.neighbours.items():
            lines.append(' "%s (%s)" <%s>' % (neighbour.display_name, neighbour.host, neighbour.uri))
        show_notice(lines)

    def _CH_trace(self, *types):
        if not types:
            lines = []
            lines.append('SIP tracing to console is now %s' % ('active' if self.logger.sip_to_stdout else 'inactive'))
            lines.append('MSRP tracing to console is now %s' % ('active' if self.logger.msrp_to_stdout else 'inactive'))
            lines.append('PJSIP tracing to console is now %s' % ('active' if self.logger.pjsip_to_stdout else 'inactive'))
            lines.append('Notification tracing to console is now %s' % ('active' if self.logger.notifications_to_stdout else 'inactive'))
            show_notice(lines)
            return

        add_types = [type[1:] for type in types if type[0] == '+']
        remove_types = [type[1:] for type in types if type[0] == '-']
        toggle_types = [type for type in types if type[0] not in ('+', '-')]

        settings = SIPSimpleSettings()

        if 'sip' in add_types or ('sip' in toggle_types and not self.logger.sip_to_stdout):
            self.logger.sip_to_stdout = True
            settings.logs.trace_sip = True
            show_notice('SIP tracing to console is now activated')
        elif 'sip' in remove_types or ('sip' in toggle_types and self.logger.sip_to_stdout):
            self.logger.sip_to_stdout = False
            settings.logs.trace_sip = False
            show_notice('SIP tracing to console is now deactivated')

        if 'msrp' in add_types or ('msrp' in toggle_types and not self.logger.msrp_to_stdout):
            self.logger.msrp_to_stdout = True
            settings.logs.trace_msrp = True
            show_notice('MSRP tracing to console is now activated')
        elif 'msrp' in remove_types or ('msrp' in toggle_types and self.logger.msrp_to_stdout):
            self.logger.msrp_to_stdout = False
            settings.logs.trace_msrp = False
            show_notice('MSRP tracing to console is now deactivated')

        if 'pjsip' in add_types or ('pjsip' in toggle_types and not self.logger.pjsip_to_stdout):
            self.logger.pjsip_to_stdout = True
            settings.logs.trace_pjsip = True
            show_notice('PJSIP tracing to console is now activated')
        elif 'pjsip' in remove_types or ('pjsip' in toggle_types and self.logger.pjsip_to_stdout):
            self.logger.pjsip_to_stdout = False
            settings.logs.trace_pjsip = False
            show_notice('PJSIP tracing to console is now deactivated')

        if 'notifications' in add_types or ('notifications' in toggle_types and not self.logger.notifications_to_stdout):
            self.logger.notifications_to_stdout = True
            settings.logs.trace_notifications = True
            show_notice('Notification tracing to console is now activated')
        elif 'notifications' in remove_types or ('notifications' in toggle_types and self.logger.notifications_to_stdout):
            self.logger.notifications_to_stdout = False
            settings.logs.trace_notifications = False
            show_notice('Notification tracing to console is now deactivated')

        settings.save()

    def _CH_rtp(self, state='toggle'):
        # `/rtp` now controls only CONSOLE output of stats.  The
        # background thread itself is auto-started whenever a video
        # call is active (to feed the HUD) and stays running for
        # the call's lifetime regardless of this toggle.
        if state == 'toggle':
            new_console = not (self.rtp_statistics is not None and self.rtp_statistics.console)
        elif state == 'on':
            new_console = True
        elif state == 'off':
            new_console = False
        else:
            raise TypeError()
        if self.rtp_statistics is None:
            self.rtp_statistics = RTPStatisticsThread(console=new_console)
            self.rtp_statistics.start()
        else:
            self.rtp_statistics.console = new_console
        if new_console:
            show_notice('Output of RTP statistics and ICE negotiation results on console is now activated')
        else:
            show_notice('Output of RTP statistics and ICE negotiation results on console is now deactivated')

    def _CH_mute(self, state='toggle'):
        if state == 'toggle':
            self.voice_audio_mixer.muted = not self.voice_audio_mixer.muted
        elif state == 'on':
            self.voice_audio_mixer.muted = True
        elif state == 'off':
            self.voice_audio_mixer.muted = False
        show_notice('The microphone is now %s' % ('muted' if self.voice_audio_mixer.muted else 'unmuted'))

    def _CH_camera(self, device=None):
        engine = Engine()
        settings = SIPSimpleSettings()
        video_devices = [None] + sorted(engine.video_devices)
        if device is None:
            if settings.video.device in video_devices:
                old_device = settings.video.device
            else:
                old_device = None
            new_device = video_devices[(video_devices.index(old_device)+1) % len(video_devices)]
            settings.video.device = new_device
            settings.save()
            show_notice('Video device changed to %s' % new_device)
        else:
            device = device.decode(sys.getfilesystemencoding())
            if device == 'None':
                device = None
            elif device not in video_devices:
                show_notice('Unknown video device %s. Type /devices to see a list of available devices' % device)
                return
            try:
                settings.video.device = new_device
            except SIPCoreError as e:
                show_notice('Failed to set video device to %s: %s' % (device, str(e)))
            else:
                show_notice('Video device changed to %s' % new_device)

    def _CH_input(self, device=None):
        engine = Engine()
        settings = SIPSimpleSettings()
        input_devices = [None, 'system_default'] + sorted(engine.input_devices)
        if device is None:
            if self.voice_audio_mixer.input_device in input_devices:
                old_input_device = self.voice_audio_mixer.input_device
            else:
                old_input_device = None
            tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0
            idx = input_devices.index(old_input_device)
            candidates = input_devices[idx+1:] + input_devices[:idx+1]
            for new_input_device in candidates:
                try:
                    self.voice_audio_mixer.set_sound_devices(new_input_device, self.voice_audio_mixer.output_device, tail_length)
                except SIPCoreError as e:
                    show_notice('Skipping input device %s: %s' % (new_input_device, str(e)))
                    continue
                if self.voice_audio_mixer.input_device != new_input_device:
                    show_notice('Skipping input device %s: device failed to start' % new_input_device)
                    continue
                if new_input_device == 'system_default':
                    show_notice('Input device changed to %s (system default device)' % self.voice_audio_mixer.real_input_device)
                else:
                    show_notice('Input device changed to %s' % new_input_device)

                settings.audio.input_device = new_input_device
                settings.save()
                break

        else:
            device = device.decode(sys.getfilesystemencoding())
            if device == 'None':
                device = None
            elif device not in input_devices:
                show_notice('Unknown input device %s. Type /devices to see a list of available devices' % device)
                return
            tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0
            try:
                self.voice_audio_mixer.set_sound_devices(device, self.voice_audio_mixer.output_device, self.voice_audio_mixer.tail_length)
            except SIPCoreError as e:
                show_notice('Failed to set input device to %s: %s' % (device, str(e)))
            else:
                if device == 'system_default':
                    show_notice('Input device changed to %s (system default device)' % self.voice_audio_mixer.real_input_device)
                else:
                    show_notice('Input device changed to %s' % device)

                settings.audio.input_device = device
                settings.save()


    def _CH_output(self, device=None):
        engine = Engine()
        settings = SIPSimpleSettings()
        output_devices = [None, 'system_default'] + sorted(engine.output_devices)
        if device is None:
            if self.voice_audio_mixer.output_device in output_devices:
                old_output_device = self.voice_audio_mixer.output_device
            else:
                old_output_device = None
            tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0
            # Try each device after the current one, skipping any that fail
            # to open (e.g. an S/PDIF output refusing mono), so a broken
            # device cannot block cycling.
            idx = output_devices.index(old_output_device)
            candidates = output_devices[idx+1:] + output_devices[:idx+1]
            for new_output_device in candidates:
                try:
                    self.voice_audio_mixer.set_sound_devices(self.voice_audio_mixer.input_device, new_output_device, tail_length)
                except SIPCoreError as e:
                    show_notice('Skipping output device %s: %s' % (new_output_device, str(e)))
                    continue
                if self.voice_audio_mixer.output_device != new_output_device:
                    # older cores swallow the failure inside the mixer;
                    # detect it by the device attribute not updating
                    show_notice('Skipping output device %s: device failed to start' % new_output_device)
                    continue
                if new_output_device == 'system_default':
                    show_notice('Output device changed to %s (system default device)' % self.voice_audio_mixer.real_output_device)
                else:
                    show_notice('Output device changed to %s' % new_output_device)

                settings.audio.output_device = new_output_device
                settings.save()
                break
        else:
            device = device.decode(sys.getfilesystemencoding())
            if device == 'None':
                device = None
            elif device not in output_devices:
                show_notice('Unknown output device %s. Type /devices to see a list of available devices' % device)
                return
            tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0
            try:
                self.voice_audio_mixer.set_sound_devices(self.voice_audio_mixer.input_device, device, tail_length)
            except SIPCoreError as e:
                show_notice('Failed to set output device to %s: %s' % (device, str(e)))
            else:
                if device == 'system_default':
                    show_notice('Output device changed to %s (system default device)' % self.voice_audio_mixer.real_output_device)
                else:
                    show_notice('Output device changed to %s' % device)
                settings.audio.output_device = device
                settings.save()

    def _CH_alert(self, device=None):
        engine = Engine()
        settings = SIPSimpleSettings()
        output_devices = [None, 'system_default'] + sorted(engine.output_devices)
        if device is None:
            if self.alert_audio_mixer.output_device in output_devices:
                old_output_device = self.alert_audio_mixer.output_device
            else:
                old_output_device = None
            tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0
            idx = output_devices.index(old_output_device)
            candidates = output_devices[idx+1:] + output_devices[:idx+1]
            for new_output_device in candidates:
                try:
                    self.alert_audio_mixer.set_sound_devices(self.alert_audio_mixer.input_device, new_output_device, tail_length)
                except SIPCoreError as e:
                    show_notice('Skipping alert device %s: %s' % (new_output_device, str(e)))
                    continue
                if self.alert_audio_mixer.output_device != new_output_device:
                    show_notice('Skipping alert device %s: device failed to start' % new_output_device)
                    continue
                if new_output_device == 'system_default':
                    show_notice('Alert device changed to %s (system default device)' % self.alert_audio_mixer.real_output_device)
                else:
                    show_notice('Alert device changed to %s' % new_output_device)
                settings.audio.alert_device = new_output_device
                settings.save()
                break
        else:
            device = device.decode(sys.getfilesystemencoding())
            if device == 'None':
                device = None
            elif device not in output_devices:
                show_notice('Unknown output device %s. Type /devices to see a list of available devices' % device)
                return
            tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0
            try:
                self.alert_audio_mixer.set_sound_devices(self.alert_audio_mixer.input_device, device, tail_length)
            except SIPCoreError as e:
                show_notice('Failed to set alert device to %s: %s' % (device, str(e)))
            else:
                if device == 'system_default':
                    show_notice('Alert device changed to %s (system default device)' % self.alert_audio_mixer.real_output_device)
                else:
                    show_notice('Alert device changed to %s' % device)
                settings.audio.alert_device = device
                settings.save()

    def _CH_devices(self):
        engine = Engine()
        settings = SIPSimpleSettings()
        show_notice('Available audio input devices: %s' % ', '.join(['None', 'system_default'] + sorted(engine.input_devices)), bold=False)
        show_notice('Available audio output devices: %s' % ', '.join(['None', 'system_default'] + sorted(engine.output_devices)), bold=False)
        if self.voice_audio_mixer.input_device == 'system_default':
            show_notice('Using audio input device: %s (system default device)' % self.voice_audio_mixer.real_input_device, bold=False)
        else:
            show_notice('Using audio input device: %s' % self.voice_audio_mixer.input_device, bold=False)

        if self.voice_audio_mixer.output_device == 'system_default':
            show_notice('Using audio output device: %s (system default device)' % self.voice_audio_mixer.real_output_device, bold=False)
        else:
            show_notice('Using audio output device: %s' % self.voice_audio_mixer.output_device, bold=False)

        if self.alert_audio_mixer.output_device == 'system_default':
            show_notice('Using audio alert device: %s (system default device)' % self.alert_audio_mixer.real_output_device, bold=False)
        else:
            show_notice('Using audio alert device: %s' % self.alert_audio_mixer.output_device, bold=False)

        if engine.video_devices:
            show_notice('Available cameras: %s' % ', '.join(sorted(engine.video_devices)))
            show_notice('Selected camera: %s' % settings.video.device)

        else:
            if self.enable_video:
                show_notice('No camera present, video is disabled')
                self.enable_video = False

    def _CH_help(self):
        self._print_help()

    def _CH_quit(self):
        self.stop()

    def _CH_eof(self):
        ui = UI()
        if self.active_session is not None:
            if self.active_session in self.sessions_with_proposals:
                ui.status = 'Cancelling proposal...'
                try:
                    self.active_session.cancel_proposal()
                except IllegalStateError:
                    self.outgoing_session.end()
            else:
                ui.status = 'Ending SIP session...'
                self.active_session.end()
        elif self.outgoing_session is not None:
            ui.status = 'Cancelling SIP session...'
            self.outgoing_session.end()
        elif self.pending_reconnect is not None and self.pending_reconnect.active():
            self.pending_reconnect.cancel()
            self.pending_reconnect = None
            show_notice('Reconnect cancelled')
        elif self.message_session_to:
            # Inside a chat: Ctrl-D exits the chat, not the program.
            try:
                message_session = next((session for session in self.message_sessions if session.target == self.message_session_to and self.account == session.account))
            except StopIteration:
                self.message_session_to = None
            else:
                show_notice('Leaving message session with %s' % self.message_session_to)
                message_session.end()
                try:
                    self.message_sessions.remove(message_session)
                except KeyError:
                    pass
                self.message_session_to = None
            self._update_prompt()
        else:
            self.stop()

    def _CH_hangup(self):
        if self.active_session is not None:
            show_notice('Ending SIP session...')
            self.active_session.end()
        elif self.outgoing_session is not None:
            show_notice('Cancelling SIP session...')
            self.outgoing_session.end()
        elif self.pending_call is not None:
            show_notice('Cancelling call to %s during DNS lookup...' % self.pending_call.target)
            self.pending_call.cancel()
        else:
            try:
                message_session = next((session for session in self.message_sessions if session.target == self.message_session_to and self.account == session.account))
            except StopIteration:
                pass
            else:
                message_session.end()
                try:
                    self.message_sessions.remove(message_session)
                except KeyError:
                    show_notice('Message session not found')
                    pass
            self.message_session_to = None
            self._update_prompt()

    @run_in_green_thread
    def _CH_dtmf(self, tones):
        if self.active_session is not None:
            audio_stream = next((stream for stream in self.active_session.streams if stream.type == 'audio'), None)
            if audio_stream is not None:
                notification_center = NotificationCenter()
                for digit in tones:
                    filename = 'sounds/dtmf_%s_tone.wav' % {'*': 'star', '#': 'pound'}.get(digit, digit)
                    wave_player = WavePlayer(self.voice_audio_mixer, ResourcePath(filename).normalized)
                    notification_center.add_observer(self, sender=wave_player)
                    audio_stream.send_dtmf(digit)
                    if self.active_session.account.rtp.inband_dtmf:
                        audio_stream.bridge.add(wave_player)
                    self.voice_audio_bridge.add(wave_player)
                    wave_player.start()
                    api.sleep(0.3)

    def _CH_record(self, state='toggle'):
        if self.active_session is None:
            return
        audio_stream = next((stream for stream in self.active_session.streams if stream.type == 'audio'), None)
        if audio_stream is not None:
            if state == 'toggle':
                new_state = audio_stream.recorder is None
            elif state == 'on':
                new_state = True
            elif state == 'off':
                new_state = False
            else:
                show_notice('Illegal argument to /record. Type /help for a list of available commands.')
                return
            if new_state:
                settings = SIPSimpleSettings()
                direction = self.active_session.direction
                remote = "%s@%s" % (self.active_session.remote_identity.uri.user, self.active_session.remote_identity.uri.host)
                filename = "%s-%s-%s.wav" % (datetime.now().strftime("%Y%m%d-%H%M%S"), remote, direction)
                path = os.path.join(settings.audio.directory.normalized, self.active_session.account.id, datetime.now().strftime("%Y%m%d"))
                try:
                    audio_stream.start_recording(os.path.join(path, filename))
                except RuntimeError as e:
                    show_notice(str(e))
            else:
                try:
                    audio_stream.stop_recording()
                except RuntimeError as e:
                    show_notice(str(e))

    def _CH_account(self, *args):
        account_manager = AccountManager()

        if not args or args[0] == 'list':
            show_notice('Accounts available: %s' % ", ".join(account.id for account in account_manager.iter_accounts() if account.enabled))

        elif args and args[0] == 'register':
            if isinstance(self.account, BonjourAccount):
                show_notice('Bonjour account has no registrar')
            else:
                if not self.account.sip.register:
                    self.registration_succeeded[self.account.id] = False

            self.account.sip.register = not self.account.sip.register
            self.account.save()

        elif args and args[0] == 'enroll':
            settings = SIPSimpleSettings()
            try:
                username = args[1].strip()
                password = args[2].strip()
                email = args[3].strip()
                display_name = " ".join(args[4:])

                if not "@" in email:
                    show_notice('Email address is not valid, must be user@domain')
                    return
                    
                if not display_name:
                    show_notice('Please specify a display name')
                    return

                sip_address = '%s@%s' % (username, settings.enrollment.default_domain)
                if account_manager.has_account(sip_address):
                    show_notice('Account %s is already defined' % sip_address)
                    return
            except IndexError:
                show_notice('To enroll an account you must provide: username password email display_name')
                return
            else:
                data = self.enroll(username, password, email, display_name)

                if data['error']:
                    show_notice(data['error'])
                else:
                    show_notice('Account %s created' % data['sip_address'])

        elif args:
            new_account = args[0]
            possible_accounts = [account for account in account_manager.iter_accounts() if new_account in account.id and account.enabled]
            if len(possible_accounts) > 1:
                show_notice('More than one account exists which matches %s: %s' % (new_account, ', '.join(sorted(account.id for account in possible_accounts))), bold=False)
            elif len(possible_accounts) == 0:
                show_notice('No enabled account which matches %s was found. Available and enabled accounts: %s' % (self.options.account, ', '.join(sorted(possible_accounts))), bold=False)
            elif possible_accounts[0] == self.account:
                show_notice('Same account selected')
            elif possible_accounts[0] != self.account:
                self.switch_account(possible_accounts[0])

    def switch_account(self, account):
        if isinstance(self.account, Account):
            self.account.sip.register = False
            self.account.save()
        
        self.registration_succeeded[self.account.id] = False

        self.account = account
        show_notice('Switching account to %s' % self.account.id)

        notification_center = NotificationCenter()
        notification_center.add_observer(self, sender=account)
            
        if isinstance(self.account, Account):
            self.account.sip.register = True
            self.account.save()
       
        self._update_prompt()

    def enroll(self, username, password, email, display_name):
        settings = SIPSimpleSettings()

        tzname = datetime.now(tzlocal()).tzname() or ""

        post_data = {'password'     : password.encode("utf8"),
                     'username'     : username.encode("utf8"),
                     'email'        : email.encode("utf8"),
                     'display_name' : display_name.encode("utf8"),
                     'tzinfo'       : tzname }

        return_data = {}

        # Run the HTTP POST on a real OS thread. The notification dispatcher
        # runs us inside an eventlib greenlet with a small C stack, which
        # makes deep call chains inside requests/urllib3/SSL surface as
        # "RecursionError: maximum recursion depth exceeded".
        url = settings.enrollment.url
        post_result = {}

        def _do_post():
            restore_ssl = lambda: None
            try:
                ok, info = _ensure_sslcontext_verify_mode_safe()
                post_result['ssl_patch'] = '%s (%s)' % (ok, info)
                restore_ssl, restore_info = _restore_stdlib_ssl_for_request()
                post_result['ssl_restore'] = restore_info
                post_result['response'] = requests.post(url, timeout=10, data=post_data)
            except BaseException as exc:
                post_result['exception'] = exc
                post_result['traceback'] = traceback.format_exc()
            finally:
                try:
                    restore_ssl()
                except Exception:
                    pass

        import threading
        post_thread = threading.Thread(target=_do_post, name='enroll-http', daemon=True)
        post_thread.start()
        post_thread.join(timeout=15)

        if post_thread.is_alive():
            return_data['error'] = "Enrollment exception: request to %s timed out" % url
            return return_data

        if 'exception' in post_result:
            exc = post_result['exception']
            return_data['error'] = (
                "Enrollment exception: %s: %s\n"
                "[ssl patch: %s]\n"
                "[ssl restore: %s]\n"
                "%s" % (
                    type(exc).__name__, exc,
                    post_result.get('ssl_patch', '<not run>'),
                    post_result.get('ssl_restore', '<not run>'),
                    post_result.get('traceback', '')))
            return return_data

        r = post_result['response']

        try:
            settings = SIPSimpleSettings()
            show_notice("Enrollment request sent to %s" % settings.enrollment.url)

            if r.status_code == 200:
                body = r.json()
                if not body["success"]:
                    return_data['error'] = "Enrollment failed: %s" % body["error_message"]
                else:
                    data = defaultdict(lambda: None, body)
                    sip_address = data['sip_address']
                    outbound_proxy = data['outbound_proxy']

                    try:
                        account = Account(sip_address)
                    except ValueError as e:
                        return_data['error'] = "Local account create error: %s" % str(e)
                    else:
                        account.auth.password = password
                        account.sip.always_use_my_proxy = True
                        account.sip.register = True
                        account.rtp.srtp_encryption = 'opportunistic'
                        account.nat_traversal.msrp_relay = data['msrp_relay']

                        if data['outbound_proxy']:
                            account.sip.outbound_proxy = data['outbound_proxy']

                        if data['xcap_root']:
                            account.xcap.xcap_root = data['xcap_root']
                            account.xcap.enabled = True

                        account.enabled = True
                        account.save()
                        account_manager = AccountManager()
                        account_manager.default_account = account

                        self.switch_account(account)
                    
                    return_data = data
            else:
                return_data['error'] = "Enrollment server error code %s" % r.status_code

        except Exception as e:
            return_data['error'] = "Enrollment exception: %s: %s\n%s" % (
                type(e).__name__, e, traceback.format_exc())

        return return_data

    def _CH_otr(self):
        if self.active_session is None:
            message_session = self.message_session(self.message_session_to)
            
            if not message_session:
                return
            
            if message_session.encryption.active:
                show_notice("Message encryption will stop")
                message_session.encryption.stop()
            else:
                show_notice("Message encryption will start")
                message_session.encryption.start()
            
            return
        
        chat_stream = next((stream for stream in self.active_session.streams if stream.type == 'chat'), None)
        
        if chat_stream is None:
            return

        if chat_stream.encryption.active:
            show_notice("Chat encryption will stop")
            chat_stream.encryption.stop()
        else:
            show_notice("Chat encryption will start")
            chat_stream.encryption.start()

    def _CH_otr_secret(self, *args):
        secret = " ".join(args) if args else None
        if secret is None and self.smp_secret:
            show_notice("OTR secret answer is: %s" % self.smp_secret)
            return

        self.smp_secret = secret
        show_notice("OTR secret answer is now set to: %s" % secret)

    def _CH_otr_answer(self, *args):
        answer = " ".join(args) if args else None

        if self.active_session is None:
            message_session = self.message_session(self.message_session_to)
            
            if not message_session:
                show_notice("OTR functions can be used only during active chat sessions")
                return

            message_session.encryption.smp_answer(answer.encode())
            
        else:
            chat_stream = next((stream for stream in self.active_session.streams if stream.type == 'chat'), None)
        
            if chat_stream is None:
                show_notice("OTR functions can be used only during active chat sessions")
                return
            
            if answer:
                chat_stream.encryption.smp_answer(answer.encode())

    def _CH_otr_question(self, *args):
        question = " ".join(args) if args else None
        
        if self.smp_secret is None:
            show_notice("OTR question can be asked only after secret has been set with /otr_secret")
            return

        if self.active_session is None:
            message_session = self.message_session(self.message_session_to)

            if not message_session:
                show_notice("OTR functions can be used only during active chat sessions")
                return

            if question:
                show_notice("OTR SMP verification question will be asked for message: %s" % question)
                message_session.encryption.verified = False
                message_session.encryption.smp_verify(self.smp_secret.encode(), question=question.encode())
                
            return

        chat_stream = next((stream for stream in self.active_session.streams if stream.type == 'chat'), None)
        audio_stream = next((stream for stream in self.active_session.streams if stream.type == 'audio'), None)
                
        if chat_stream is None:
            show_notice("OTR functions can be used only during active chat sessions")
            return
            
        if self.smp_secret is None:
            show_notice("First set the SMP secret answer using /otr_secret command")
            return
         
        if question:
            show_notice("OTR SMP verification question will be asked: %s" % question)
            chat_stream.encryption.verified = False
            chat_stream.encryption.smp_verify(self.smp_secret.encode(), question=question.encode())

    def _CH_key(self, action=None):
        if action not in ('send', 'show', 'export', 'generate', 'lookup'):
            show_notice('Usage: /key {send|show|export|generate|lookup}')
            return

        if not hasattr(self.account, 'sms'):
            show_notice('Account %s does not support PGP keys' % self.account.id)
            return

        if action == 'generate':
            private_key_path = getattr(self.account.sms, 'private_key', None)
            public_key_path = getattr(self.account.sms, 'public_key', None)
            key_exists = ((private_key_path and os.path.exists(private_key_path)) or
                          (public_key_path and os.path.exists(public_key_path)))

            if key_exists:
                if self.question is not None:
                    show_notice('Another question is currently pending; please answer it first')
                    return
                self.pending_key_generate_account = self.account
                self.question = Question(
                    "A new PGP key will REPLACE the existing one for %s and be uploaded to the server. "
                    "Previously encrypted messages will not be readable on devices that don't have the new key. "
                    "Proceed? (y)es/(n)o" % self.account.id,
                    'yn', bold=True)
                notification_center = NotificationCenter()
                notification_center.add_observer(self, sender=self.question)
                ui = UI()
                ui.add_question(self.question)
                return

            self._generate_pgp_key(self.account)
            return

        if action == 'lookup':
            self._lookup_public_key(str(self.account.id))
            return

        public_key_path = getattr(self.account.sms, 'public_key', None)
        private_key_path = getattr(self.account.sms, 'private_key', None)

        if not public_key_path:
            show_notice('No PGP public key is configured for account %s. Use /key generate to create one.' % self.account.id)
            return

        try:
            with open(public_key_path, 'r') as fd:
                public_key = fd.read()
        except (OSError, IOError) as e:
            show_notice('Cannot read PGP public key from %s: %s' % (public_key_path, e))
            return

        if action == 'show':
            # The key ID first and on its own line: it is the part a person
            # reads out to compare against another device, and the armoured
            # block below it is not something anybody compares by eye.
            lines = ['PGP public key for %s, key ID %s (%s):'
                     % (self.account.id, public_key_short_checksum(public_key), public_key_path)]
            lines.extend(public_key.splitlines())
            show_notice(lines, bold=False)
            return

        if action == 'send':
            # In a conversation the key goes to the remote party. Outside
            # one it goes to our own account, which is how the server is
            # told that this is the public key to hand out for us.
            to_server = not self.message_session_to
            target = str(self.account.id) if to_server else self.message_session_to

            message_session = self.message_session(target, silent=to_server)

            if not message_session:
                show_notice('Could not create a message session to %s' % target)
                return

            message_session.send_message(public_key, content_type='text/pgp-public-key')
            if to_server:
                show_notice('PGP public key of %s, key ID %s, sent to the server as the active key'
                            % (target, public_key_short_checksum(public_key)))
            else:
                show_notice('PGP public key sent to %s, key ID %s'
                            % (target, public_key_short_checksum(public_key)))
            return

        # action == 'export' — send symmetrically-encrypted private key to own account
        if not private_key_path:
            show_notice('No PGP private key is configured for account %s.' % self.account.id)
            return

        try:
            with open(private_key_path, 'r') as fd:
                private_key = fd.read()
        except (OSError, IOError) as e:
            show_notice('Cannot read PGP private key from %s: %s' % (private_key_path, e))
            return

        # 6-digit random pincode, same scheme as blink: two 3-digit blocks zero-padded
        pincode = ''.join([str(random.randint(0, 999)).zfill(3) for _ in range(2)])

        try:
            pgp_message = pgpy.PGPMessage.new(private_key)
            enc_message = pgp_message.encrypt(pincode)
        except Exception as e:
            show_notice('PGP symmetric encryption failed: %s' % e)
            return

        # Ensure the public-key block is terminated by a newline so the
        # subsequent -----BEGIN PGP MESSAGE----- marker sits on its own line.
        body = public_key.rstrip() + '\n' + str(enc_message)

        target = str(self.account.id)
        message_session = self.message_session(target, silent=True)
        if not message_session:
            show_notice('Could not create message session to own account %s' % target)
            return

        message_session.send_message(body, content_type='text/pgp-private-key')

        show_notice(
            ['Encrypted private key sent to %s' % target,
             'Pincode (enter on the other device to import the key): %s' % pincode],
            bold=True)

    @run_in_green_thread
    def _generate_pgp_key(self, account):
        try:
            from pgpy.constants import PubKeyAlgorithm, KeyFlags, HashAlgorithm, SymmetricKeyAlgorithm, CompressionAlgorithm
        except ImportError as e:
            show_notice('PGP key generation requires pgpy: %s' % e)
            return

        show_notice('Generating a new 4096-bit RSA PGP key for %s, this may take a while...' % account.id)

        try:
            key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 4096)
            display_name = account.display_name or str(account.id)
            uid = pgpy.PGPUID.new(str(display_name), email=str(account.id))
            key.add_uid(
                uid,
                usage={KeyFlags.Sign, KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
                hashes=[HashAlgorithm.SHA256, HashAlgorithm.SHA384, HashAlgorithm.SHA512, HashAlgorithm.SHA224],
                ciphers=[SymmetricKeyAlgorithm.AES256, SymmetricKeyAlgorithm.AES192, SymmetricKeyAlgorithm.AES128],
                compression=[CompressionAlgorithm.ZLIB, CompressionAlgorithm.BZ2, CompressionAlgorithm.ZIP, CompressionAlgorithm.Uncompressed],
            )
        except Exception as e:
            show_notice('PGP key generation failed: %s' % e)
            return

        private_key_content = str(key)
        public_key_content = str(key.pubkey)

        private_key_path = "%s/%s.privkey" % (self.keys_path, account.id)
        public_key_path = "%s/%s.pubkey" % (self.keys_path, account.id)

        try:
            with open(private_key_path, 'w') as fd:
                fd.write(private_key_content)
            try:
                os.chmod(private_key_path, 0o600)
            except OSError:
                pass
            show_notice('Private key saved to %s' % private_key_path)

            with open(public_key_path, 'w') as fd:
                fd.write(public_key_content)
            show_notice('Public key saved to %s, key ID %s'
                        % (public_key_path, public_key_short_checksum(public_key_content)))
        except (OSError, IOError) as e:
            show_notice('Could not save PGP key: %s' % e)
            return

        account.sms.private_key = private_key_path
        account.sms.public_key = public_key_path
        account.sms.public_key_checksum = hashlib.sha1(public_key_content.encode()).hexdigest()
        account.save()

        # drop any cached copies so the new key is picked up
        self.private_keys.pop(account.id, None)
        self.public_keys.pop(str(account.id), None)

        # Upload the new public key to the server so other devices on this
        # account can find it. The server stores it; subsequent lookups
        # (startup or /m from another device) will return this key.
        if not isinstance(account, BonjourAccount):
            try:
                target = str(account.id)
                message_session = self.message_session(target, account=account, silent=True)
                message_session.send_message(public_key_content,
                                             content_type='text/pgp-public-key')
                show_notice('New public key uploaded to %s' % target)
            except Exception as e:
                show_notice('Could not upload new public key: %s' % e)

        show_notice('PGP key for %s generated. Use /key export to share the private key with your other devices.' % account.id)

    def _CH_zrtp_verified(self):
        if self.active_session is None:
            show_notice("ZRTP functions can be used only during active audio sessions")
            return

        audio_stream = next((stream for stream in self.active_session.streams if stream.type == 'audio'), None)

        if audio_stream is None:
            show_notice("ZRTP functions can be used only during active audio sessions")
            return

        # Two possible encryption layers: RFC 6189 ZRTP (touched via
        # audio_stream.encryption.zrtp) and Sylk-flavoured ZRTP-over-
        # SIP-MESSAGE (the SylkZRTPSession attached to the session as
        # _sylk_zrtp). Reaching for audio_stream.encryption.zrtp when
        # only Sylk-ZRTP is active raises "ZRTP options have not been
        # initialized" — check which is in play before deciding what to
        # toggle.
        sylk_session = getattr(self.active_session, '_sylk_zrtp', None)
        sylk_active = sylk_session is not None and getattr(sylk_session, 'state', None) in ('key-agreed', 'key-active')
        if sylk_active:
            ok = False
            try:
                ok = bool(sylk_session.confirm_sas_and_seed_rs1())
            except Exception as e:
                show_notice("Sylk-ZRTP verify failed: %s" % e)
                return
            if ok:
                show_notice("Sylk-ZRTP SAS confirmed — rs1 seeded for %s (continuity will engage on the next call)"
                            % getattr(sylk_session, 'peer_aor', '?'))
            else:
                show_notice("Sylk-ZRTP verify: rs1 not seeded (peer_aor or shared secret missing)")
            self._update_prompt()
            return

        # Fallback to RFC 6189 ZRTP toggle.
        try:
            audio_stream.encryption.zrtp.verified = not audio_stream.encryption.zrtp.verified
            show_notice("ZRTP peer is now %s" % ('verified' if audio_stream.encryption.zrtp.verified else 'not verified'))
        except RuntimeError as e:
            show_notice("ZRTP not active on this call (%s)" % e)

    def _CH_zrtp_pgp_keys(self, *args):
        """Plumb PGP keys into the active call's Sylk-ZRTP session for v3
        signed-handshake testing. Usage:

            /zrtp_pgp_keys <local_priv.asc> <peer_pub.asc>

        Both args are filesystem paths to armored OpenPGP key blocks.
        Either may be '-' to leave the corresponding slot unchanged.
        Keys take effect for SUBSEQUENT probe / accept payloads on this
        call (the first probe/accept may have already fired without
        sig). For full v3 protection on call setup, applications should
        invoke session._sylk_zrtp.set_signing_keys(...) at session
        creation time before start_probe runs."""
        if self.active_session is None:
            show_notice("/zrtp_pgp_keys requires an active session")
            return
        sylk_session = getattr(self.active_session, '_sylk_zrtp', None)
        if sylk_session is None:
            show_notice("/zrtp_pgp_keys: no Sylk-ZRTP session on this call")
            return
        if len(args) < 2:
            show_notice("Usage: /zrtp_pgp_keys <local_priv.asc> <peer_pub.asc>")
            return
        local_path, peer_path = args[0], args[1]
        local_blob = peer_blob = None
        try:
            if local_path and local_path != '-':
                with open(os.path.expanduser(local_path), 'r') as fh:
                    local_blob = fh.read()
            if peer_path and peer_path != '-':
                with open(os.path.expanduser(peer_path), 'r') as fh:
                    peer_blob = fh.read()
        except Exception as e:
            show_notice("/zrtp_pgp_keys read failed: %s" % e)
            return
        try:
            sylk_session.set_signing_keys(local_blob, peer_blob)
        except Exception as e:
            show_notice("/zrtp_pgp_keys set_signing_keys failed: %s" % e)
            return
        show_notice("Sylk-ZRTP signing keys updated "
                    "(local_priv=%s, peer_pub=%s)"
                    % ('yes' if local_blob else 'unchanged',
                       'yes' if peer_blob else 'unchanged'))

    def _CH_zrtp_reset(self):
        """Forget the Sylk-ZRTP retained secret (rs1) for the peer of the
        currently active call. Use to re-bootstrap the v2 continuity
        handshake from the first-time state (e.g. for testing). Does NOT
        affect RFC 6189 ZRTP."""
        if self.active_session is None:
            show_notice("/zrtp_reset can be used only during an active session")
            return
        sylk_session = getattr(self.active_session, '_sylk_zrtp', None)
        if sylk_session is None:
            show_notice("/zrtp_reset: no Sylk-ZRTP session on this call")
            return
        peer_aor = getattr(sylk_session, 'peer_aor', None) or '?'
        try:
            ok = bool(sylk_session.clear_rs1())
        except Exception as e:
            show_notice("/zrtp_reset failed: %s" % e)
            return
        if ok:
            show_notice("Sylk-ZRTP rs1 cleared for %s (this call still uses the agreed keys; the next call will start in continuity=first-time)"
                        % peer_aor)
        else:
            show_notice("/zrtp_reset: no rs1 was stored for %s" % peer_aor)
        self._update_prompt()

    def _CH_zrtp_name(self, *args):
        if self.active_session is None:
            show_notice("ZRTP functions can be used only during active audio sessions")
            return

        audio_stream = next((stream for stream in self.active_session.streams if stream.type == 'audio'), None)
        
        if audio_stream is None:
            show_notice("ZRTP functions can be used only during active audio sessions")
            return
            
        peer_name = " ".join(args) if args else None
        audio_stream.encryption.zrtp.peer_name = peer_name
        show_notice("ZRTP peer name is now %s" % audio_stream.encryption.zrtp.peer_name)

    def _CH_hold(self, state='toggle'):
        if self.active_session is not None:
            if state == 'toggle':
                new_state = not self.active_session.on_hold
            elif state == 'on':
                new_state = True
            elif state == 'off':
                new_state = False
            else:
                show_notice('Illegal argument to /hold. Type /help for a list of available commands.')
                return
            if new_state:
                self.active_session.hold()
            else:
                self.active_session.unhold()

    def _CH_add(self, stream_name):
        if self.active_session is None:
            show_notice('There is no active session')
            return
        if stream_name not in ('audio', 'chat', 'video'):
            show_notice('Illegal argument to /add. Type /help for a list of available commands.')
            return
        if stream_name in (stream.type for stream in self.active_session.streams):
            show_notice('The active session already has a %s stream' % stream_name)
            return
        try:
            proposal_handler = OutgoingProposalHandler(self.active_session, **{stream_name: True})
        except ValueError as e:
            show_notice('Cannot add %s stream: %s' % (stream_name, e))
            return
        try:
            proposal_handler.start()
        except IllegalStateError:
            show_notice('Cannot add a stream while another transaction is in progress')

    def _CH_remove(self, stream_name):
        if self.active_session is None:
            show_notice('There is no active session')
            return
        try:
            stream = next((stream for stream in self.active_session.streams if stream.type==stream_name))
        except StopIteration:
            show_notice('The current active session does not have any %s streams' % stream_name)
        else:
            try:
                self.active_session.remove_stream(stream)
            except IllegalStateError:
                show_notice('Cannot remove a stream while another transaction is in progress')

    def _CH_add_participant(self, uri):
        if self.active_session is None:
            show_notice('There is no active session')
            return
        if re.match('^(sip:|sips:)', uri) is None:
            uri = 'sip:%s' % uri
        try:
            uri = SIPURI.parse(uri)
        except SIPCoreError:
            show_notice('Invalid SIP URI')
        else:
            self.active_session.conference.add_participant(uri)

    def _CH_remove_participant(self, uri):
        if self.active_session is None:
            show_notice('There is no active session')
            return
        if re.match('^(sip:|sips:)', uri) is None:
            uri = 'sip:%s' % uri
        try:
            uri = SIPURI.parse(uri)
        except SIPCoreError:
            show_notice('Invalid SIP URI')
        else:
            self.active_session.conference.remove_participant(uri)

    def _CH_transfer(self, uri):
        if self.active_session is None:
            show_notice('There is no active session')
            return
        if re.match('^(sip:|sips:)', uri) is None:
            uri = 'sip:%s' % uri
            
        if '@' not in uri:
            uri = '%s@%s' % (uri, self.account.id.domain)

        try:
            uri = SIPURI.parse(uri)
        except SIPCoreError:
            show_notice('Invalid SIP URI')
        else:
            self.active_session.transfer(uri)

    def _CH_nickname(self, nickname):
        if self.active_session is not None:
            try:
                chat_stream = next((stream for stream in self.active_session.streams if stream.type=='chat'))
            except StopIteration:
                return
            try:
                chat_stream.set_local_nickname(nickname)
            except Exception as e:
                show_notice('Error setting nickname: %s' % e)

    # private methods
    #

    def _print_help(self):
        lines = []
        lines.append('General commands:')
        lines.append('  /account [list|register|user@domain|enroll user password email display_name]: account management')
        lines.append('  /audio {user[@domain]} [+chat]: call the specified user using audio and possibly chat')
        lines.append('  /video {user[@domain]} [+chat]: call the specified user using audio and video, and possibly chat')
        lines.append('  /chat {user[@domain]} [+audio]: call the specified user using chat and possibly audio')
        lines.append('  /conf {room}: join conference room using chat and audio')
        lines.append('  /load {room} [capacity] [timeout] [soundfile]: load-test - ramp [capacity] audio calls (default 30) to {room} at 1/sec, hold [timeout]s (default 120, 0 = until stop), then hang up all. Legs stay active (never on hold); the active leg carries your mic, the rest are silent load. Pass [soundfile] (or "noise") to inject audio into non-active legs.')
        lines.append('  /load add {n}: add n more legs to the running load test')
        lines.append('  /load remove {n}: hang up n of the running load test legs')
        lines.append('  /load stop: hang up all load-test legs and end the test')
        lines.append('  /[message | m] {user[@domain]}: start a message session')
        lines.append('  /send {user[@domain]} {file}: initiate a file transfer with the specified user')
        lines.append('  /next: select the next connected session')
        lines.append('  /prev: select the previous connected session')
        lines.append('  /sessions: show the list of connected sessions')
        if isinstance(self.account, BonjourAccount):
            lines.append('  /[neighbours | n]: show the list of bonjour neighbours')
        lines.append('  /trace [[+|-]sip] [[+|-]msrp] [[+|-]pjsip] [[+|-]notifications]: toggle/set tracing on the console (ctrl-x s | ctrl-x m | ctrl-x j | ctrl-x n)')
        lines.append('  /rtp [on|off]: toggle/set printing RTP statistics and ICE negotiation results on the console (ctrl-x p)')
        lines.append('  /mute [on|off]: mute the microphone (ctrl-x u)')
        lines.append('  /camera [device]: change camera device (ctrl-x c)')
        lines.append('  /video open: open an on-screen window showing the remote video of the active session')
        lines.append('  /video close: close the video display window for the active session')
        lines.append('  /input [device]: change audio input device (ctrl-x i)')
        lines.append('  /output [device]: change audio output device (ctrl-x o)')
        lines.append('  /alert [device]: change audio alert device (ctrl-x a)')
        lines.append('  /key generate: generate a new PGP key pair for the current account, upload it to the server (asks to confirm if one already exists)')
        lines.append('  /key show: display your PGP public key on the screen')
        lines.append('  /key send: send your PGP public key to the remote party in the current message session, or, outside a message session, to the server as the active key for your account')
        lines.append('  /key export: send your PGP private key, symmetrically encrypted with a random 6-digit pincode, to your own account (for import on another device)')
        lines.append('  /key lookup: ask the server whether it has a PGP public key for your account')
        lines.append('  /quit: quit the program (ctrl-x q)')
        lines.append('  /help: display this help message (ctrl-x ?)')
        lines.append('In call commands:')
        lines.append('  /hangup: hang-up the active session (ctrl-x h)')
        lines.append('  /dtmf {0-9|*|#|A-D}...: send DTMF tones (ctrl-x 0-9|*|#|A-D)')
        lines.append('  /record [on|off]: toggle/set audio recording (ctrl-x r)')
        lines.append('  /hold [on|off]: hold/unhold (ctrl-x SPACE)')
        lines.append('  /zrtp_verified: toggle verified flag for ZRTP peer (both parties must do it)')
        lines.append('  /zrtp_reset: forget the Sylk-ZRTP retained secret (rs1) for this peer; next call re-bootstraps from continuity=first-time')
        lines.append('  /zrtp_pgp_keys <local_priv.asc> <peer_pub.asc>: load PGP keys (armored) for v3 signed-handshake; use "-" to leave a slot unchanged')
        lines.append('  /zrtp_name name: set name for ZRTP peer')
        if self.otr_enabled:
            lines.append('  /otr: toggle OTR encryption for the chat stream')
            lines.append('  /otr_answer answer: Answer OTR verification question using SMP protocol')
            lines.append('  /otr_secret [secret]: show or set OTR secret')
            lines.append('  /otr_question question: Ask OTR verification question using SMP protocol')
        lines.append('  /add {chat|audio|video}: add a stream to the current session')
        lines.append('  /remove {chat|audio}: remove a stream from the current session')
        lines.append('  /add_participant {user@domain}: add the specified user to the conference')
        lines.append('  /remove_participant {user@domain}: remove the specified user from the conference')
        lines.append('  /ptt [press|release|status] [participant]: key / unkey all radios in the conference, or only those of the given participant; without an action it toggles')
        lines.append('  /transfer {user@domain}: transfer (using blind transfer) callee to the specified destination')
        lines.append('  /nickname nick: set the user nickname whithin a conference')
        show_notice(lines, bold=False)

    def _update_prompt(self):
        ui = UI()
        session = self.active_session

        if session is not None:
            info = []
            try:
                user = session.remote_identity.uri.user
                host = session.remote_identity.uri.host
                if isinstance(user, bytes):
                    user = user.decode()
                if isinstance(host, bytes):
                    host = host.decode()
                identity = '%s@%s' % (user, host)
            except Exception:
                identity = str(getattr(session.remote_identity, 'uri', ''))
            if session.remote_identity.display_name:
                identity = '%s (%s)' % (session.remote_identity.display_name, identity)
            streams = '/'.join(stream.type.capitalize() for stream in session.streams)
            chat_stream = next((stream for stream in session.streams if stream.type == 'chat'), None)
            audio_stream = next((stream for stream in session.streams if stream.type == 'audio'), None)

            # Track whether every encryption layer reports verified so the
            # prompt foreground can flip from red to green when the call
            # is fully trusted. Starts True and gets cleared on any
            # NOT verified / MISMATCH appended below.
            all_verified = True

            if chat_stream and chat_stream.encryption.active:
                if chat_stream.encryption.verified:
                    info.append('Chat encrypted verified')
                else:
                    info.append('Chat encrypted NOT verified')
                    all_verified = False

            if audio_stream and audio_stream.encryption.active:
                # Sylk-flavoured ZRTP layers on top of SRTP/SDES, so the
                # underlying audio_stream.encryption.type is 'SRTP/SDES'
                # (not 'ZRTP') when our X25519-over-SIP-MESSAGE handshake
                # is what's actually wrapping the media. Check the
                # SylkZRTPSession first; only fall through to RFC 6189
                # ZRTP when no Sylk-ZRTP session is in play.
                sylk = getattr(session, '_sylk_zrtp', None)
                sylk_state = getattr(sylk, 'state', None) if sylk is not None else None
                if sylk is not None and sylk_state in ('key-agreed', 'key-active'):
                    cs = getattr(sylk, 'continuity_state', 'first-time')
                    pv = getattr(sylk, 'negotiated_version', 1)
                    label = 'Sylk-ZRTP v%d' % pv
                    if cs == 'verified':
                        info.append('Audio encrypted verified (%s)' % label)
                    elif cs == 'mismatch':
                        info.append('Audio encrypted MISMATCH (%s)' % label)
                        all_verified = False
                    else:
                        info.append('Audio encrypted NOT verified (%s)' % label)
                        all_verified = False
                else:
                    zrtp = None
                    if audio_stream.encryption.type == 'ZRTP':
                        try:
                            zrtp = audio_stream.encryption.zrtp
                        except RuntimeError:
                            zrtp = None
                    if zrtp is not None:
                        if zrtp.verified:
                            if zrtp.peer_name:
                                info.append('Audio encrypted verified with %s' % zrtp.peer_name)
                            else:
                                info.append('Audio encrypted verified')
                        else:
                            info.append('Audio encrypted NOT verified')
                            all_verified = False
                    else:
                        info.append('Audio encrypted NOT verified')
                        all_verified = False

            if not info:
                info = ['not encrypted']
                all_verified = False
            # Green when every encryption layer reports verified; red
            # for any NOT verified / MISMATCH / unencrypted state.
            prompt_fg = 'darkgreen' if all_verified else 'darkred'
            ui.prompt = Prompt('%s to %s (%s)' % (streams, identity, ", ".join(info)), foreground=prompt_fg)
            return

        if self.message_session_to:
            ui.prompt = Prompt('Chat with %s' % self.message_session_to, foreground='darkred')
            return

        ui.prompt = Prompt('%s@%s' % (self.account.id.username, self.account.id.domain), foreground='default')


_saved_terminal = None


def _save_terminal():
    global _saved_terminal
    try:
        import termios
        if sys.stdin.isatty():
            _saved_terminal = (sys.stdin.fileno(), termios.tcgetattr(sys.stdin.fileno()))
    except Exception:
        _saved_terminal = None


def _restore_terminal():
    try:
        sys.__stdout__.write('\n')
        sys.__stdout__.flush()
    except Exception:
        pass
    if _saved_terminal is None:
        return
    try:
        import termios
        fd, attrs = _saved_terminal
        termios.tcsetattr(fd, termios.TCSANOW, attrs)
    except Exception:
        pass


def _ts():
    return datetime.now().strftime('%Y-%m-%d %H:%M:%S ')


def _split_users(value):
    if not value:
        return []
    return [item.strip() for item in value.split(',') if item.strip()]


def parse_handle_call_option(option, opt_str, value, parser, name):
    try:
        value = parser.rargs[0]
    except IndexError:
        value = 0
    else:
        if value == '' or value[0] == '-':
            value = 0
        else:
            try:
                value = int(value)
            except ValueError:
                value = 0
            else:
                del parser.rargs[0]
    setattr(parser.values, name, value)


if __name__ == '__main__':
    description = '%prog is a command-line client for handling multiple audio, chat and file-transfer sessions'
    usage = '%prog [options] [user@domain] [filename]'
    parser = OptionParser(usage=usage, description=description)
    parser.print_usage = parser.print_help
    parser.add_option('-a', '--account', type='string', dest='account', help='The account name to use for any outgoing traffic. If not supplied, the default account will be used.', metavar='NAME')
    parser.add_option('-c', '--config-directory', type='string', dest='config_directory', help='The configuration directory to use. This overrides the default location.')
    parser.add_option('--playback-dir', type='string', dest='playback_dir', help='Directory with wav files to be played after calling. The destination SIP address is taken from the filename. After playback the call is hangup')
    parser.add_option('-r', '--auto-record', action='store_true', dest='auto_record', default=False, help='Automatic recording of voice calls.')
    parser.add_option('-s', '--trace-sip', action='store_true', dest='trace_sip', default=False, help='Dump the raw contents of incoming and outgoing SIP messages.')
    parser.add_option('-p', '--enable_playback', action='store_true', dest='enable_playback', default=False, help='Enable polling playback directory for new wavs.')
    parser.add_option('-m', '--trace-msrp', action='store_true', dest='trace_msrp', default=False, help='Dump msrp logging information and the raw contents of incoming and outgoing MSRP messages.')
    parser.add_option('-j', '--trace-pjsip', action='store_true', dest='trace_pjsip', default=False, help='Print PJSIP logging output.')
    parser.add_option('-n', '--trace-notifications', action='store_true', dest='trace_notifications', default=False, help='Print all notifications (disabled by default).')
    parser.add_option('-S', '--disable-sound', action='store_true', dest='disable_sound', default=False, help='Disables initializing the sound card.')
    parser.add_option('-R', '--auto-reconnect', action='store_true', dest='auto_reconnect', default=False, help='Auto reconnect calls if disconnected by remote.')
    parser.add_option('-v', '--video', action='store_true', dest='with_video', default=False, help='Place the outgoing call with a video stream (only meaningful when a target SIP URI is given on the command line). Audio only by default; pass --chat to additionally include an MSRP chat stream at call start.')
    parser.add_option('--chat', action='store_true', dest='with_chat', default=False, help='Include an MSRP chat stream in the initial INVITE. Without this flag the call starts audio-only (no MSRP media is present at call start); chat can still be added later via re-INVITE.')
    parser.add_option('--no-chat', action='store_true', dest='no_chat', default=False, help='Deprecated/no-op: audio-only (no chat) is now the default. Kept for backwards compatibility.')
    parser.add_option('--dump', action='store_true', dest='dump', default=False, help='Capture pcap of signaling + audio + video traffic for every active session into ~/.sipclient/logs/<call-id>/capture.pcap (uses tcpdump; macOS prompts for sudo on first start).')
    parser.set_default('auto_answer_interval', None)
    parser.add_option('--auto-answer', action='callback', callback=parse_handle_call_option, callback_args=('auto_answer_interval',), help='Interval after which to answer an incoming session (disabled by default). If the option is specified but the interval is not, it defaults to 0 (accept the session as soon as it starts ringing).', metavar='[INTERVAL]')
    parser.set_default('auto_hangup_interval', None)
    parser.add_option('--auto-hangup', action='callback', callback=parse_handle_call_option, callback_args=('auto_hangup_interval',), help='Interval after which to hang up an established session (disabled by default). If the option is specified but the interval is not, it defaults to 0 (hangup the session as soon as it connects).', metavar='[INTERVAL]')
    parser.add_option('-i', '--invite-on-join', type='string', dest='invite', default=None, help='Comma separated list of users (user@domain) to invite once the session is established, if the remote party is a conference focus.', metavar='A,B,...')
    parser.add_option('-l', '--hangup-on-leave', type='string', dest='hangup_on_leave', default=None, help='Comma separated list of users (user or user@domain). If the remote party is a conference focus, hang up once all of them have left the conference (only counts users that joined).', metavar='A,B,...')
    parser.add_option('--join-when-invited', type='string', dest='join_when_invited', default=None, help='Do not call at start; wait and join a conference when invited to it (the conference server calls us from the room; an incoming REFER is honored too). Without a value any invitation is accepted; with =A,B,... and/or a target URI only invitations whose conference URI or inviter matches one of them (user or user@domain).', metavar='[=A,B,...]')
    # --join-when-invited takes an optional value: the bare form means "any"
    sys.argv[1:] = ['--join-when-invited=*' if arg == '--join-when-invited' else arg for arg in sys.argv[1:]]
    parser.add_option('--shutdown-timeout', type='float', dest='shutdown_timeout', default=10, help='Seconds to wait for a graceful shutdown (sessions ended, accounts unregistered) before forcing the exit (default 10).', metavar='SECONDS')
    options, args = parser.parse_args()
    if options.join_when_invited is not None:
        options.join_when_invited = [item for item in _split_users(options.join_when_invited) if item != '*']

    target = args[0] if args else None
    filepath = args[1] if len(args) == 2 else None

    if options.join_when_invited is not None and target is not None:
        # With --join-when-invited the target is not called: it is the
        # conference we wait to be invited to.
        options.join_when_invited.append(target)
        target = None
        filepath = None

    _save_terminal()
    application = SIPSessionApplication()
    application.start(target, options, filepath)

    signal.signal(signal.SIGINT, signal.SIG_DFL)

    # SIGTERM/SIGHUP (systemctl stop, closed terminal): shut down gracefully,
    # ending sessions and unregistering, bounded by --shutdown-timeout. A
    # second signal exits immediately.
    def _graceful_stop(signum, frame):
        if application.stopping:
            _restore_terminal()
            os._exit(1)
        application.stop()
    signal.signal(signal.SIGTERM, _graceful_stop)
    signal.signal(signal.SIGHUP, _graceful_stop)

    application.stopped_event.wait()
    sleep(0.1)
