summaryrefslogtreecommitdiff
path: root/pjsip-apps/src/pygui/chat.py
diff options
context:
space:
mode:
Diffstat (limited to 'pjsip-apps/src/pygui/chat.py')
-rw-r--r--pjsip-apps/src/pygui/chat.py489
1 files changed, 489 insertions, 0 deletions
diff --git a/pjsip-apps/src/pygui/chat.py b/pjsip-apps/src/pygui/chat.py
new file mode 100644
index 00000000..760af1c7
--- /dev/null
+++ b/pjsip-apps/src/pygui/chat.py
@@ -0,0 +1,489 @@
+# $Id$
+#
+# 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
+
+# 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:
+ print "=== %s cannot find participant with URI '%s'" % (op, uri_str)
+ 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
+ sender = None
+ if self._callList[idx] and self._callList[idx].connected:
+ sender = self._callList[idx]
+ else:
+ sender = self._buddyList[idx]
+ assert(sender)
+
+ try:
+ sender.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
+ sender = None
+ if self._callList[idx] and self._callList[idx].connected:
+ sender = self._callList[idx]
+ else:
+ sender = self._buddyList[idx]
+ assert(sender)
+
+ try:
+ sender.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.findBuddy(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)
+ med_idx = self._getActiveMediaIdx(thecall)
+ si = thecall.getStreamInfo(med_idx)
+ stats_str = "Audio codec: %s/%s\n..." % (si.codecName, si.codecClockRate)
+ self._gui.audioSetStatsText(thecall.peerUri, stats_str)
+ 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))
+
+
+ # ** 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()