1
# $Id: chat.py 4757 2014-02-21 07:53:31Z nanang $
3
# pjsua Python GUI Demo
5
# Copyright (C)2013 Teluu Inc. (http://www.teluu.com)
7
# This program is free software; you can redistribute it and/or modify
8
# it under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 2 of the License, or
10
# (at your option) any later version.
12
# This program is distributed in the hope that it will be useful,
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
17
# You should have received a copy of the GNU General Public License
18
# along with this program; if not, write to the Free Software
19
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
22
if sys.version_info[0] >= 3: # Python 3
24
from tkinter import ttk
36
SipUriRegex = re.compile('(sip|sips):([^:;>\@]*)@?([^:;>]*):?([^:;>]*)')
39
# Simple SIP uri parser, input URI must have been validated
40
def ParseSipUri(sip_uri_str):
41
m = SipUriRegex.search(sip_uri_str)
54
return SipUri(scheme.lower(), user, host.lower(), port)
57
def __init__(self, scheme, user, host, port):
63
def __cmp__(self, sip_uri):
64
if self.scheme == sip_uri.scheme and self.user == sip_uri.user and self.host == sip_uri.host:
65
# don't check port, at least for now
71
if self.user: s += self.user + '@'
73
if self.port: s+= ':' + self.port
76
class Chat(gui.ChatObserver):
77
def __init__(self, app, acc, uri, call_inst=None):
83
self.confIdx = ConfIdx
86
# each participant call/buddy instances are stored in call list
87
# and buddy list with same index as in particpant list
88
self._participantList = [] # list of SipUri
89
self._callList = [] # list of Call
90
self._buddyList = [] # list of Buddy
92
self._gui = gui.ChatFrame(self)
93
self.addParticipant(uri, call_inst)
97
self.title = str(self._participantList[0])
99
self.title = 'Conference #%d (%d participants)' % (self.confIdx, len(self._participantList))
100
self._gui.title(self.title)
101
self._app.updateWindowMenu()
103
def _getCallFromUriStr(self, uri_str, op = ''):
104
uri = ParseSipUri(uri_str)
105
if uri not in self._participantList:
106
print "=== %s cannot find participant with URI '%s'" % (op, uri_str)
108
idx = self._participantList.index(uri)
109
if idx < len(self._callList):
110
return self._callList[idx]
113
def _getActiveMediaIdx(self, thecall):
114
ci = thecall.getInfo()
116
if mi.type == pj.PJMEDIA_TYPE_AUDIO and \
117
(mi.status != pj.PJSUA_CALL_MEDIA_NONE and \
118
mi.status != pj.PJSUA_CALL_MEDIA_ERROR):
122
def _getAudioMediaFromUriStr(self, uri_str):
123
c = self._getCallFromUriStr(uri_str)
124
if not c: return None
126
idx = self._getActiveMediaIdx(c)
127
if idx < 0: return None
130
am = pj.AudioMedia.typecastFromMedia(m)
133
def _sendTypingIndication(self, is_typing, sender_uri_str=''):
134
sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None
135
type_ind_param = pj.SendTypingIndicationParam()
136
type_ind_param.isTyping = is_typing
137
for idx, p in enumerate(self._participantList):
138
# don't echo back to the original sender
139
if sender_uri and p == sender_uri:
142
# send via call, if any, or buddy
144
if self._callList[idx] and self._callList[idx].connected:
145
target = self._callList[idx]
147
target = self._buddyList[idx]
151
target.sendTypingIndication(type_ind_param)
155
def _sendInstantMessage(self, msg, sender_uri_str=''):
156
sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None
157
send_im_param = pj.SendInstantMessageParam()
158
send_im_param.content = str(msg)
159
for idx, p in enumerate(self._participantList):
160
# don't echo back to the original sender
161
if sender_uri and p == sender_uri:
164
# send via call, if any, or buddy
166
if self._callList[idx] and self._callList[idx].connected:
167
target = self._callList[idx]
169
target = self._buddyList[idx]
173
target.sendInstantMessage(send_im_param)
175
# error will be handled via Account::onInstantMessageStatus()
179
return len(self._participantList) <= 1
181
def isUriParticipant(self, uri):
182
return uri in self._participantList
184
def registerCall(self, uri_str, call_inst):
185
uri = ParseSipUri(uri_str)
187
idx = self._participantList.index(uri)
188
bud = self._buddyList[idx]
189
self._callList[idx] = call_inst
190
call_inst.chat = self
191
call_inst.peerUri = bud.cfg.uri
193
assert(0) # idx must be found!
195
def showWindow(self, show_text_chat = False):
196
self._gui.bringToFront()
198
self._gui.textShowHide(True)
200
def addParticipant(self, uri, call_inst=None):
202
if self.isUriParticipant(uri): return
206
# find buddy, create one if not found (e.g: for IM/typing ind),
207
# it is a temporary one and not really registered to acc
210
bud = self._acc.findBuddy(uri_str)
212
bud = buddy.Buddy(None)
213
bud_cfg = pj.BuddyConfig()
214
bud_cfg.uri = uri_str
215
bud_cfg.subscribe = False
216
bud.create(self._acc, bud_cfg)
218
bud.account = self._acc
220
# update URI from buddy URI
221
uri = ParseSipUri(bud.cfg.uri)
224
self._participantList.append(uri)
225
self._callList.append(call_inst)
226
self._buddyList.append(bud)
227
self._gui.addParticipant(str(uri))
230
def kickParticipant(self, uri):
231
if (not uri) or (uri not in self._participantList):
235
idx = self._participantList.index(uri)
236
del self._participantList[idx]
237
del self._callList[idx]
238
del self._buddyList[idx]
239
self._gui.delParticipant(str(uri))
241
if self._participantList:
246
def addMessage(self, from_uri_str, msg):
248
# print message on GUI
249
msg = from_uri_str + ': ' + msg
250
self._gui.textAddMessage(msg)
251
# now relay to all participants
252
self._sendInstantMessage(msg, from_uri_str)
254
self._gui.textAddMessage(msg, False)
256
def setTypingIndication(self, from_uri_str, is_typing):
258
self._gui.textSetTypingIndication(from_uri_str, is_typing)
259
# now relay to all participants
260
self._sendTypingIndication(is_typing, from_uri_str)
263
self._gui.enableAudio()
264
call_param = pj.CallOpParam()
265
call_param.opt.audioCount = 1
266
call_param.opt.videoCount = 0
268
for idx, p in enumerate(self._participantList):
269
# just skip if call is instantiated
270
if self._callList[idx]:
274
c = call.Call(self._acc, uri_str, self)
275
self._callList[idx] = c
276
self._gui.audioUpdateState(uri_str, gui.AudioState.INITIALIZING)
279
c.makeCall(uri_str, call_param)
281
self._callList[idx] = None
282
self._gui.audioUpdateState(uri_str, gui.AudioState.FAILED)
286
# kick participants with call failure, but spare the last (avoid zombie chat)
287
if not self.isPrivate():
288
self.kickParticipant(p)
291
for idx, p in enumerate(self._participantList):
292
self._gui.audioUpdateState(str(p), gui.AudioState.DISCONNECTED)
293
c = self._callList[idx]
295
c.hangup(pj.CallOpParam())
297
def updateCallState(self, thecall, info = None):
298
# info is optional here, just to avoid calling getInfo() twice (in the caller and here)
299
if not info: info = thecall.getInfo()
301
if info.state < pj.PJSIP_INV_STATE_CONFIRMED:
302
self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.INITIALIZING)
303
elif info.state == pj.PJSIP_INV_STATE_CONFIRMED:
304
self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.CONNECTED)
305
if not self.isPrivate():
306
# inform peer about conference participants
307
conf_welcome_str = '\n---\n'
308
conf_welcome_str += 'Welcome to the conference, participants:\n'
309
conf_welcome_str += '%s (host)\n' % (self._acc.cfg.idUri)
310
for p in self._participantList:
311
conf_welcome_str += '%s\n' % (str(p))
312
conf_welcome_str += '---\n'
313
send_im_param = pj.SendInstantMessageParam()
314
send_im_param.content = conf_welcome_str
316
thecall.sendInstantMessage(send_im_param)
320
# inform others, including self
321
msg = "[Conf manager] %s has joined" % (thecall.peerUri)
322
self.addMessage(None, msg)
323
self._sendInstantMessage(msg, thecall.peerUri)
325
elif info.state == pj.PJSIP_INV_STATE_DISCONNECTED:
326
if info.lastStatusCode/100 != 2:
327
self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.FAILED)
329
self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.DISCONNECTED)
331
# reset entry in the callList
333
idx = self._callList.index(thecall)
334
if idx >= 0: self._callList[idx] = None
338
self.addMessage(None, "Call to '%s' disconnected: %s" % (thecall.peerUri, info.lastReason))
340
# kick the disconnected participant, but the last (avoid zombie chat)
341
if not self.isPrivate():
342
self.kickParticipant(ParseSipUri(thecall.peerUri))
344
# inform others, including self
345
msg = "[Conf manager] %s has left" % (thecall.peerUri)
346
self.addMessage(None, msg)
347
self._sendInstantMessage(msg, thecall.peerUri)
349
def updateCallMediaState(self, thecall, info = None):
350
# info is optional here, just to avoid calling getInfo() twice (in the caller and here)
351
if not info: info = thecall.getInfo()
353
med_idx = self._getActiveMediaIdx(thecall)
355
self._gui.audioSetStatsText(thecall.peerUri, 'No active media')
358
si = thecall.getStreamInfo(med_idx)
363
if si.dir & pj.PJMEDIA_DIR_ENCODING:
365
if si.dir & pj.PJMEDIA_DIR_DECODING:
366
dir_str += 'receive '
367
stats_str = "Direction : %s\n" % (dir_str)
368
stats_str += "Audio codec : %s (%sHz)" % (si.codecName, si.codecClockRate)
369
self._gui.audioSetStatsText(thecall.peerUri, stats_str)
370
m = pj.AudioMedia.typecastFromMedia(thecall.getMedia(med_idx))
373
for c in self._callList:
376
med_idx = self._getActiveMediaIdx(c)
379
mm = pj.AudioMedia.typecastFromMedia(c.getMedia(med_idx))
384
# ** callbacks from GUI (ChatObserver implementation) **
387
def onSendMessage(self, msg):
388
self._sendInstantMessage(msg)
390
def onStartTyping(self):
391
self._sendTypingIndication(True)
393
def onStopTyping(self):
394
self._sendTypingIndication(False)
397
def onHangup(self, peer_uri_str):
398
c = self._getCallFromUriStr(peer_uri_str, "onHangup()")
400
call_param = pj.CallOpParam()
403
def onHold(self, peer_uri_str):
404
c = self._getCallFromUriStr(peer_uri_str, "onHold()")
406
call_param = pj.CallOpParam()
407
c.setHold(call_param)
409
def onUnhold(self, peer_uri_str):
410
c = self._getCallFromUriStr(peer_uri_str, "onUnhold()")
413
call_param = pj.CallOpParam()
414
call_param.opt.audioCount = 1
415
call_param.opt.videoCount = 0
416
call_param.opt.flag |= pj.PJSUA_CALL_UNHOLD
417
c.reinvite(call_param)
419
def onRxMute(self, peer_uri_str, mute):
420
am = self._getAudioMediaFromUriStr(peer_uri_str)
423
am.stopTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
424
self.addMessage(None, "Muted audio from '%s'" % (peer_uri_str))
426
am.startTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
427
self.addMessage(None, "Unmuted audio from '%s'" % (peer_uri_str))
429
def onRxVol(self, peer_uri_str, vol_pct):
430
am = self._getAudioMediaFromUriStr(peer_uri_str)
432
# pjsua volume range = 0:mute, 1:no adjustment, 2:100% louder
433
am.adjustRxLevel(vol_pct/50.0)
434
self.addMessage(None, "Adjusted volume level audio from '%s'" % (peer_uri_str))
436
def onTxMute(self, peer_uri_str, mute):
437
am = self._getAudioMediaFromUriStr(peer_uri_str)
440
ep.Endpoint.instance.audDevManager().getCaptureDevMedia().stopTransmit(am)
441
self.addMessage(None, "Muted audio to '%s'" % (peer_uri_str))
443
ep.Endpoint.instance.audDevManager().getCaptureDevMedia().startTransmit(am)
444
self.addMessage(None, "Unmuted audio to '%s'" % (peer_uri_str))
447
def onAddParticipant(self):
449
dlg = AddParticipantDlg(None, self._app, buds)
452
uri = ParseSipUri(bud.cfg.uri)
453
self.addParticipant(uri)
454
if not self.isPrivate():
457
def onStartAudio(self):
460
def onStopAudio(self):
463
def onCloseWindow(self):
465
# will remove entry from list eventually destroy this chat?
466
if self in self._acc.chatList: self._acc.chatList.remove(self)
467
self._app.updateWindowMenu()
472
class AddParticipantDlg(tk.Toplevel):
476
def __init__(self, parent, app, bud_list):
477
tk.Toplevel.__init__(self, parent)
478
self.title('Add participants..')
479
self.transient(parent)
482
self.buddyList = bud_list
490
self.parent.wait_window(self)
492
self.wait_window(self)
495
def createWidgets(self):
497
list_frame = ttk.Frame(self)
498
list_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=1, padx=20, pady=20)
499
#scrl = ttk.Scrollbar(self, orient=tk.VERTICAL, command=list_frame.yview)
500
#list_frame.config(yscrollcommand=scrl.set)
501
#scrl.pack(side=tk.RIGHT, fill=tk.Y)
505
for acc in self._app.accList:
506
self.buddies.append((0, acc.cfg.idUri))
507
for bud in acc.buddyList:
508
self.buddies.append((1, bud))
511
for idx,(flag,bud) in enumerate(self.buddies):
512
self.bud_var.append(tk.IntVar())
514
s = ttk.Separator(list_frame, orient=tk.HORIZONTAL)
516
l = tk.Label(list_frame, anchor=tk.W, text="Account '%s':" % (bud))
519
c = tk.Checkbutton(list_frame, anchor=tk.W, text=bud.cfg.uri, variable=self.bud_var[idx])
521
s = ttk.Separator(list_frame, orient=tk.HORIZONTAL)
525
tail_frame = ttk.Frame(self)
526
tail_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=1)
528
btnOk = ttk.Button(tail_frame, text='Ok', default=tk.ACTIVE, command=self.onOk)
529
btnOk.pack(side=tk.LEFT, padx=20, pady=10)
530
btnCancel = ttk.Button(tail_frame, text='Cancel', command=self.onCancel)
531
btnCancel.pack(side=tk.RIGHT, padx=20, pady=10)
534
self.buddyList[:] = []
535
for idx,(flag,bud) in enumerate(self.buddies):
536
if not flag: continue
537
if self.bud_var[idx].get() and not (bud in self.buddyList):
538
self.buddyList.append(bud)