pjproject/pjsip-apps/src/pygui/chat.py

545 lines
18 KiB
Python

#
# pjsua Python GUI Demo
#
# Copyright (C)2013 Teluu Inc. (http://www.teluu.com)
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
import sys
if sys.version_info[0] >= 3: # Python 3
import tkinter as tk
from tkinter import ttk
else:
import Tkinter as tk
import ttk
import buddy
import call
import chatgui as gui
import endpoint as ep
import pjsua2 as pj
import re
SipUriRegex = re.compile('(sip|sips):([^:;>\@]*)@?([^:;>]*):?([^:;>]*)')
ConfIdx = 1
write=sys.stdout.write
# Simple SIP uri parser, input URI must have been validated
def ParseSipUri(sip_uri_str):
m = SipUriRegex.search(sip_uri_str)
if not m:
assert(0)
return None
scheme = m.group(1)
user = m.group(2)
host = m.group(3)
port = m.group(4)
if host == '':
host = user
user = ''
return SipUri(scheme.lower(), user, host.lower(), port)
class SipUri:
def __init__(self, scheme, user, host, port):
self.scheme = scheme
self.user = user
self.host = host
self.port = port
def __cmp__(self, sip_uri):
if self.scheme == sip_uri.scheme and self.user == sip_uri.user and self.host == sip_uri.host:
# don't check port, at least for now
return 0
return -1
def __str__(self):
s = self.scheme + ':'
if self.user: s += self.user + '@'
s += self.host
if self.port: s+= ':' + self.port
return s
class Chat(gui.ChatObserver):
def __init__(self, app, acc, uri, call_inst=None):
self._app = app
self._acc = acc
self.title = ''
global ConfIdx
self.confIdx = ConfIdx
ConfIdx += 1
# each participant call/buddy instances are stored in call list
# and buddy list with same index as in particpant list
self._participantList = [] # list of SipUri
self._callList = [] # list of Call
self._buddyList = [] # list of Buddy
self._gui = gui.ChatFrame(self)
self.addParticipant(uri, call_inst)
def _updateGui(self):
if self.isPrivate():
self.title = str(self._participantList[0])
else:
self.title = 'Conference #%d (%d participants)' % (self.confIdx, len(self._participantList))
self._gui.title(self.title)
self._app.updateWindowMenu()
def _getCallFromUriStr(self, uri_str, op = ''):
uri = ParseSipUri(uri_str)
if uri not in self._participantList:
write("=== "+ op +" cannot find participant with URI '" + uri_str + "'\r\n")
return None
idx = self._participantList.index(uri)
if idx < len(self._callList):
return self._callList[idx]
return None
def _getActiveMediaIdx(self, thecall):
ci = thecall.getInfo()
for mi in ci.media:
if mi.type == pj.PJMEDIA_TYPE_AUDIO and \
(mi.status != pj.PJSUA_CALL_MEDIA_NONE and \
mi.status != pj.PJSUA_CALL_MEDIA_ERROR):
return mi.index
return -1
def _getAudioMediaFromUriStr(self, uri_str):
c = self._getCallFromUriStr(uri_str)
if not c: return None
idx = self._getActiveMediaIdx(c)
if idx < 0: return None
m = c.getMedia(idx)
am = pj.AudioMedia.typecastFromMedia(m)
return am
def _sendTypingIndication(self, is_typing, sender_uri_str=''):
sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None
type_ind_param = pj.SendTypingIndicationParam()
type_ind_param.isTyping = is_typing
for idx, p in enumerate(self._participantList):
# don't echo back to the original sender
if sender_uri and p == sender_uri:
continue
# send via call, if any, or buddy
target = None
if self._callList[idx] and self._callList[idx].connected:
target = self._callList[idx]
else:
target = self._buddyList[idx]
assert(target)
try:
target.sendTypingIndication(type_ind_param)
except:
pass
def _sendInstantMessage(self, msg, sender_uri_str=''):
sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None
send_im_param = pj.SendInstantMessageParam()
send_im_param.content = str(msg)
for idx, p in enumerate(self._participantList):
# don't echo back to the original sender
if sender_uri and p == sender_uri:
continue
# send via call, if any, or buddy
target = None
if self._callList[idx] and self._callList[idx].connected:
target = self._callList[idx]
else:
target = self._buddyList[idx]
assert(target)
try:
target.sendInstantMessage(send_im_param)
except:
# error will be handled via Account::onInstantMessageStatus()
pass
def isPrivate(self):
return len(self._participantList) <= 1
def isUriParticipant(self, uri):
return uri in self._participantList
def registerCall(self, uri_str, call_inst):
uri = ParseSipUri(uri_str)
try:
idx = self._participantList.index(uri)
bud = self._buddyList[idx]
self._callList[idx] = call_inst
call_inst.chat = self
call_inst.peerUri = bud.cfg.uri
except:
assert(0) # idx must be found!
def showWindow(self, show_text_chat = False):
self._gui.bringToFront()
if show_text_chat:
self._gui.textShowHide(True)
def addParticipant(self, uri, call_inst=None):
# avoid duplication
if self.isUriParticipant(uri): return
uri_str = str(uri)
# find buddy, create one if not found (e.g: for IM/typing ind),
# it is a temporary one and not really registered to acc
bud = None
try:
bud = self._acc.findBuddy2(uri_str)
except:
bud = buddy.Buddy(None)
bud_cfg = pj.BuddyConfig()
bud_cfg.uri = uri_str
bud_cfg.subscribe = False
bud.create(self._acc, bud_cfg)
bud.cfg = bud_cfg
bud.account = self._acc
# update URI from buddy URI
uri = ParseSipUri(bud.cfg.uri)
# add it
self._participantList.append(uri)
self._callList.append(call_inst)
self._buddyList.append(bud)
self._gui.addParticipant(str(uri))
self._updateGui()
def kickParticipant(self, uri):
if (not uri) or (uri not in self._participantList):
assert(0)
return
idx = self._participantList.index(uri)
del self._participantList[idx]
del self._callList[idx]
del self._buddyList[idx]
self._gui.delParticipant(str(uri))
if self._participantList:
self._updateGui()
else:
self.onCloseWindow()
def addMessage(self, from_uri_str, msg):
if from_uri_str:
# print message on GUI
msg = from_uri_str + ': ' + msg
self._gui.textAddMessage(msg)
# now relay to all participants
self._sendInstantMessage(msg, from_uri_str)
else:
self._gui.textAddMessage(msg, False)
def setTypingIndication(self, from_uri_str, is_typing):
# notify GUI
self._gui.textSetTypingIndication(from_uri_str, is_typing)
# now relay to all participants
self._sendTypingIndication(is_typing, from_uri_str)
def startCall(self):
self._gui.enableAudio()
call_param = pj.CallOpParam()
call_param.opt.audioCount = 1
call_param.opt.videoCount = 0
fails = []
for idx, p in enumerate(self._participantList):
# just skip if call is instantiated
if self._callList[idx]:
continue
uri_str = str(p)
c = call.Call(self._acc, uri_str, self)
self._callList[idx] = c
self._gui.audioUpdateState(uri_str, gui.AudioState.INITIALIZING)
try:
c.makeCall(uri_str, call_param)
except:
self._callList[idx] = None
self._gui.audioUpdateState(uri_str, gui.AudioState.FAILED)
fails.append(p)
for p in fails:
# kick participants with call failure, but spare the last (avoid zombie chat)
if not self.isPrivate():
self.kickParticipant(p)
def stopCall(self):
for idx, p in enumerate(self._participantList):
self._gui.audioUpdateState(str(p), gui.AudioState.DISCONNECTED)
c = self._callList[idx]
if c:
c.hangup(pj.CallOpParam())
def updateCallState(self, thecall, info = None):
# info is optional here, just to avoid calling getInfo() twice (in the caller and here)
if not info: info = thecall.getInfo()
if info.state < pj.PJSIP_INV_STATE_CONFIRMED:
self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.INITIALIZING)
elif info.state == pj.PJSIP_INV_STATE_CONFIRMED:
self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.CONNECTED)
if not self.isPrivate():
# inform peer about conference participants
conf_welcome_str = '\n---\n'
conf_welcome_str += 'Welcome to the conference, participants:\n'
conf_welcome_str += '%s (host)\n' % (self._acc.cfg.idUri)
for p in self._participantList:
conf_welcome_str += '%s\n' % (str(p))
conf_welcome_str += '---\n'
send_im_param = pj.SendInstantMessageParam()
send_im_param.content = conf_welcome_str
try:
thecall.sendInstantMessage(send_im_param)
except:
pass
# inform others, including self
msg = "[Conf manager] %s has joined" % (thecall.peerUri)
self.addMessage(None, msg)
self._sendInstantMessage(msg, thecall.peerUri)
elif info.state == pj.PJSIP_INV_STATE_DISCONNECTED:
if info.lastStatusCode/100 != 2:
self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.FAILED)
else:
self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.DISCONNECTED)
# reset entry in the callList
try:
idx = self._callList.index(thecall)
if idx >= 0: self._callList[idx] = None
except:
pass
self.addMessage(None, "Call to '%s' disconnected: %s" % (thecall.peerUri, info.lastReason))
# kick the disconnected participant, but the last (avoid zombie chat)
if not self.isPrivate():
self.kickParticipant(ParseSipUri(thecall.peerUri))
# inform others, including self
msg = "[Conf manager] %s has left" % (thecall.peerUri)
self.addMessage(None, msg)
self._sendInstantMessage(msg, thecall.peerUri)
def updateCallMediaState(self, thecall, info = None):
# info is optional here, just to avoid calling getInfo() twice (in the caller and here)
if not info: info = thecall.getInfo()
med_idx = self._getActiveMediaIdx(thecall)
if (med_idx < 0):
self._gui.audioSetStatsText(thecall.peerUri, 'No active media')
return
si = thecall.getStreamInfo(med_idx)
dir_str = ''
if si.dir == 0:
dir_str = 'inactive'
else:
if si.dir & pj.PJMEDIA_DIR_ENCODING:
dir_str += 'send '
if si.dir & pj.PJMEDIA_DIR_DECODING:
dir_str += 'receive '
stats_str = "Direction : %s\n" % (dir_str)
stats_str += "Audio codec : %s (%sHz)" % (si.codecName, si.codecClockRate)
self._gui.audioSetStatsText(thecall.peerUri, stats_str)
m = pj.AudioMedia.typecastFromMedia(thecall.getMedia(med_idx))
# make conference
for c in self._callList:
if c == thecall:
continue
med_idx = self._getActiveMediaIdx(c)
if med_idx < 0:
continue
mm = pj.AudioMedia.typecastFromMedia(c.getMedia(med_idx))
m.startTransmit(mm)
mm.startTransmit(m)
# ** callbacks from GUI (ChatObserver implementation) **
# Text
def onSendMessage(self, msg):
self._sendInstantMessage(msg)
def onStartTyping(self):
self._sendTypingIndication(True)
def onStopTyping(self):
self._sendTypingIndication(False)
# Audio
def onHangup(self, peer_uri_str):
c = self._getCallFromUriStr(peer_uri_str, "onHangup()")
if not c: return
call_param = pj.CallOpParam()
c.hangup(call_param)
def onHold(self, peer_uri_str):
c = self._getCallFromUriStr(peer_uri_str, "onHold()")
if not c: return
call_param = pj.CallOpParam()
c.setHold(call_param)
def onUnhold(self, peer_uri_str):
c = self._getCallFromUriStr(peer_uri_str, "onUnhold()")
if not c: return
call_param = pj.CallOpParam()
call_param.opt.audioCount = 1
call_param.opt.videoCount = 0
call_param.opt.flag |= pj.PJSUA_CALL_UNHOLD
c.reinvite(call_param)
def onRxMute(self, peer_uri_str, mute):
am = self._getAudioMediaFromUriStr(peer_uri_str)
if not am: return
if mute:
am.stopTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
self.addMessage(None, "Muted audio from '%s'" % (peer_uri_str))
else:
am.startTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
self.addMessage(None, "Unmuted audio from '%s'" % (peer_uri_str))
def onRxVol(self, peer_uri_str, vol_pct):
am = self._getAudioMediaFromUriStr(peer_uri_str)
if not am: return
# pjsua volume range = 0:mute, 1:no adjustment, 2:100% louder
am.adjustRxLevel(vol_pct/50.0)
self.addMessage(None, "Adjusted volume level audio from '%s'" % (peer_uri_str))
def onTxMute(self, peer_uri_str, mute):
am = self._getAudioMediaFromUriStr(peer_uri_str)
if not am: return
if mute:
ep.Endpoint.instance.audDevManager().getCaptureDevMedia().stopTransmit(am)
self.addMessage(None, "Muted audio to '%s'" % (peer_uri_str))
else:
ep.Endpoint.instance.audDevManager().getCaptureDevMedia().startTransmit(am)
self.addMessage(None, "Unmuted audio to '%s'" % (peer_uri_str))
# Chat room
def onAddParticipant(self):
buds = []
dlg = AddParticipantDlg(None, self._app, buds)
if dlg.doModal():
for bud in buds:
uri = ParseSipUri(bud.cfg.uri)
self.addParticipant(uri)
if not self.isPrivate():
self.startCall()
def onStartAudio(self):
self.startCall()
def onStopAudio(self):
self.stopCall()
def onCloseWindow(self):
self.stopCall()
# will remove entry from list eventually destroy this chat?
if self in self._acc.chatList: self._acc.chatList.remove(self)
self._app.updateWindowMenu()
# destroy GUI
self._gui.destroy()
class AddParticipantDlg(tk.Toplevel):
"""
List of buddies
"""
def __init__(self, parent, app, bud_list):
tk.Toplevel.__init__(self, parent)
self.title('Add participants..')
self.transient(parent)
self.parent = parent
self._app = app
self.buddyList = bud_list
self.isOk = False
self.createWidgets()
def doModal(self):
if self.parent:
self.parent.wait_window(self)
else:
self.wait_window(self)
return self.isOk
def createWidgets(self):
# buddy list
list_frame = ttk.Frame(self)
list_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=1, padx=20, pady=20)
#scrl = ttk.Scrollbar(self, orient=tk.VERTICAL, command=list_frame.yview)
#list_frame.config(yscrollcommand=scrl.set)
#scrl.pack(side=tk.RIGHT, fill=tk.Y)
# draw buddy list
self.buddies = []
for acc in self._app.accList:
self.buddies.append((0, acc.cfg.idUri))
for bud in acc.buddyList:
self.buddies.append((1, bud))
self.bud_var = []
for idx,(flag,bud) in enumerate(self.buddies):
self.bud_var.append(tk.IntVar())
if flag==0:
s = ttk.Separator(list_frame, orient=tk.HORIZONTAL)
s.pack(fill=tk.X)
l = tk.Label(list_frame, anchor=tk.W, text="Account '%s':" % (bud))
l.pack(fill=tk.X)
else:
c = tk.Checkbutton(list_frame, anchor=tk.W, text=bud.cfg.uri, variable=self.bud_var[idx])
c.pack(fill=tk.X)
s = ttk.Separator(list_frame, orient=tk.HORIZONTAL)
s.pack(fill=tk.X)
# Ok/cancel buttons
tail_frame = ttk.Frame(self)
tail_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=1)
btnOk = ttk.Button(tail_frame, text='Ok', default=tk.ACTIVE, command=self.onOk)
btnOk.pack(side=tk.LEFT, padx=20, pady=10)
btnCancel = ttk.Button(tail_frame, text='Cancel', command=self.onCancel)
btnCancel.pack(side=tk.RIGHT, padx=20, pady=10)
def onOk(self):
self.buddyList[:] = []
for idx,(flag,bud) in enumerate(self.buddies):
if not flag: continue
if self.bud_var[idx].get() and not (bud in self.buddyList):
self.buddyList.append(bud)
self.isOk = True
self.destroy()
def onCancel(self):
self.destroy()