chat.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. #
  2. # pjsua Python GUI Demo
  3. #
  4. # Copyright (C)2013 Teluu Inc. (http://www.teluu.com)
  5. #
  6. # This program is free software; you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation; either version 2 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program; if not, write to the Free Software
  18. # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
  19. #
  20. import sys
  21. if sys.version_info[0] >= 3: # Python 3
  22. import tkinter as tk
  23. from tkinter import ttk
  24. else:
  25. import Tkinter as tk
  26. import ttk
  27. import buddy
  28. import call
  29. import chatgui as gui
  30. import endpoint as ep
  31. import pjsua2 as pj
  32. import re
  33. SipUriRegex = re.compile('(sip|sips):([^:;>\@]*)@?([^:;>]*):?([^:;>]*)')
  34. ConfIdx = 1
  35. write=sys.stdout.write
  36. # Simple SIP uri parser, input URI must have been validated
  37. def ParseSipUri(sip_uri_str):
  38. m = SipUriRegex.search(sip_uri_str)
  39. if not m:
  40. assert(0)
  41. return None
  42. scheme = m.group(1)
  43. user = m.group(2)
  44. host = m.group(3)
  45. port = m.group(4)
  46. if host == '':
  47. host = user
  48. user = ''
  49. return SipUri(scheme.lower(), user, host.lower(), port)
  50. class SipUri:
  51. def __init__(self, scheme, user, host, port):
  52. self.scheme = scheme
  53. self.user = user
  54. self.host = host
  55. self.port = port
  56. def __cmp__(self, sip_uri):
  57. if self.scheme == sip_uri.scheme and self.user == sip_uri.user and self.host == sip_uri.host:
  58. # don't check port, at least for now
  59. return 0
  60. return -1
  61. def __str__(self):
  62. s = self.scheme + ':'
  63. if self.user: s += self.user + '@'
  64. s += self.host
  65. if self.port: s+= ':' + self.port
  66. return s
  67. class Chat(gui.ChatObserver):
  68. def __init__(self, app, acc, uri, call_inst=None):
  69. self._app = app
  70. self._acc = acc
  71. self.title = ''
  72. global ConfIdx
  73. self.confIdx = ConfIdx
  74. ConfIdx += 1
  75. # each participant call/buddy instances are stored in call list
  76. # and buddy list with same index as in particpant list
  77. self._participantList = [] # list of SipUri
  78. self._callList = [] # list of Call
  79. self._buddyList = [] # list of Buddy
  80. self._gui = gui.ChatFrame(self)
  81. self.addParticipant(uri, call_inst)
  82. def _updateGui(self):
  83. if self.isPrivate():
  84. self.title = str(self._participantList[0])
  85. else:
  86. self.title = 'Conference #%d (%d participants)' % (self.confIdx, len(self._participantList))
  87. self._gui.title(self.title)
  88. self._app.updateWindowMenu()
  89. def _getCallFromUriStr(self, uri_str, op = ''):
  90. uri = ParseSipUri(uri_str)
  91. if uri not in self._participantList:
  92. write("=== "+ op +" cannot find participant with URI '" + uri_str + "'\r\n")
  93. return None
  94. idx = self._participantList.index(uri)
  95. if idx < len(self._callList):
  96. return self._callList[idx]
  97. return None
  98. def _getActiveMediaIdx(self, thecall):
  99. ci = thecall.getInfo()
  100. for mi in ci.media:
  101. if mi.type == pj.PJMEDIA_TYPE_AUDIO and \
  102. (mi.status != pj.PJSUA_CALL_MEDIA_NONE and \
  103. mi.status != pj.PJSUA_CALL_MEDIA_ERROR):
  104. return mi.index
  105. return -1
  106. def _getAudioMediaFromUriStr(self, uri_str):
  107. c = self._getCallFromUriStr(uri_str)
  108. if not c: return None
  109. idx = self._getActiveMediaIdx(c)
  110. if idx < 0: return None
  111. m = c.getMedia(idx)
  112. am = pj.AudioMedia.typecastFromMedia(m)
  113. return am
  114. def _sendTypingIndication(self, is_typing, sender_uri_str=''):
  115. sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None
  116. type_ind_param = pj.SendTypingIndicationParam()
  117. type_ind_param.isTyping = is_typing
  118. for idx, p in enumerate(self._participantList):
  119. # don't echo back to the original sender
  120. if sender_uri and p == sender_uri:
  121. continue
  122. # send via call, if any, or buddy
  123. target = None
  124. if self._callList[idx] and self._callList[idx].connected:
  125. target = self._callList[idx]
  126. else:
  127. target = self._buddyList[idx]
  128. assert(target)
  129. try:
  130. target.sendTypingIndication(type_ind_param)
  131. except:
  132. pass
  133. def _sendInstantMessage(self, msg, sender_uri_str=''):
  134. sender_uri = ParseSipUri(sender_uri_str) if sender_uri_str else None
  135. send_im_param = pj.SendInstantMessageParam()
  136. send_im_param.content = str(msg)
  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:
  140. continue
  141. # send via call, if any, or buddy
  142. target = None
  143. if self._callList[idx] and self._callList[idx].connected:
  144. target = self._callList[idx]
  145. else:
  146. target = self._buddyList[idx]
  147. assert(target)
  148. try:
  149. target.sendInstantMessage(send_im_param)
  150. except:
  151. # error will be handled via Account::onInstantMessageStatus()
  152. pass
  153. def isPrivate(self):
  154. return len(self._participantList) <= 1
  155. def isUriParticipant(self, uri):
  156. return uri in self._participantList
  157. def registerCall(self, uri_str, call_inst):
  158. uri = ParseSipUri(uri_str)
  159. try:
  160. idx = self._participantList.index(uri)
  161. bud = self._buddyList[idx]
  162. self._callList[idx] = call_inst
  163. call_inst.chat = self
  164. call_inst.peerUri = bud.cfg.uri
  165. except:
  166. assert(0) # idx must be found!
  167. def showWindow(self, show_text_chat = False):
  168. self._gui.bringToFront()
  169. if show_text_chat:
  170. self._gui.textShowHide(True)
  171. def addParticipant(self, uri, call_inst=None):
  172. # avoid duplication
  173. if self.isUriParticipant(uri): return
  174. uri_str = str(uri)
  175. # find buddy, create one if not found (e.g: for IM/typing ind),
  176. # it is a temporary one and not really registered to acc
  177. bud = None
  178. try:
  179. bud = self._acc.findBuddy2(uri_str)
  180. except:
  181. bud = buddy.Buddy(None)
  182. bud_cfg = pj.BuddyConfig()
  183. bud_cfg.uri = uri_str
  184. bud_cfg.subscribe = False
  185. bud.create(self._acc, bud_cfg)
  186. bud.cfg = bud_cfg
  187. bud.account = self._acc
  188. # update URI from buddy URI
  189. uri = ParseSipUri(bud.cfg.uri)
  190. # add it
  191. self._participantList.append(uri)
  192. self._callList.append(call_inst)
  193. self._buddyList.append(bud)
  194. self._gui.addParticipant(str(uri))
  195. self._updateGui()
  196. def kickParticipant(self, uri):
  197. if (not uri) or (uri not in self._participantList):
  198. assert(0)
  199. return
  200. idx = self._participantList.index(uri)
  201. del self._participantList[idx]
  202. del self._callList[idx]
  203. del self._buddyList[idx]
  204. self._gui.delParticipant(str(uri))
  205. if self._participantList:
  206. self._updateGui()
  207. else:
  208. self.onCloseWindow()
  209. def addMessage(self, from_uri_str, msg):
  210. if from_uri_str:
  211. # print message on GUI
  212. msg = from_uri_str + ': ' + msg
  213. self._gui.textAddMessage(msg)
  214. # now relay to all participants
  215. self._sendInstantMessage(msg, from_uri_str)
  216. else:
  217. self._gui.textAddMessage(msg, False)
  218. def setTypingIndication(self, from_uri_str, is_typing):
  219. # notify GUI
  220. self._gui.textSetTypingIndication(from_uri_str, is_typing)
  221. # now relay to all participants
  222. self._sendTypingIndication(is_typing, from_uri_str)
  223. def startCall(self):
  224. self._gui.enableAudio()
  225. call_param = pj.CallOpParam()
  226. call_param.opt.audioCount = 1
  227. call_param.opt.videoCount = 0
  228. fails = []
  229. for idx, p in enumerate(self._participantList):
  230. # just skip if call is instantiated
  231. if self._callList[idx]:
  232. continue
  233. uri_str = str(p)
  234. c = call.Call(self._acc, uri_str, self)
  235. self._callList[idx] = c
  236. self._gui.audioUpdateState(uri_str, gui.AudioState.INITIALIZING)
  237. try:
  238. c.makeCall(uri_str, call_param)
  239. except:
  240. self._callList[idx] = None
  241. self._gui.audioUpdateState(uri_str, gui.AudioState.FAILED)
  242. fails.append(p)
  243. for p in fails:
  244. # kick participants with call failure, but spare the last (avoid zombie chat)
  245. if not self.isPrivate():
  246. self.kickParticipant(p)
  247. def stopCall(self):
  248. for idx, p in enumerate(self._participantList):
  249. self._gui.audioUpdateState(str(p), gui.AudioState.DISCONNECTED)
  250. c = self._callList[idx]
  251. if c:
  252. c.hangup(pj.CallOpParam())
  253. def updateCallState(self, thecall, info = None):
  254. # info is optional here, just to avoid calling getInfo() twice (in the caller and here)
  255. if not info: info = thecall.getInfo()
  256. if info.state < pj.PJSIP_INV_STATE_CONFIRMED:
  257. self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.INITIALIZING)
  258. elif info.state == pj.PJSIP_INV_STATE_CONFIRMED:
  259. self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.CONNECTED)
  260. if not self.isPrivate():
  261. # inform peer about conference participants
  262. conf_welcome_str = '\n---\n'
  263. conf_welcome_str += 'Welcome to the conference, participants:\n'
  264. conf_welcome_str += '%s (host)\n' % (self._acc.cfg.idUri)
  265. for p in self._participantList:
  266. conf_welcome_str += '%s\n' % (str(p))
  267. conf_welcome_str += '---\n'
  268. send_im_param = pj.SendInstantMessageParam()
  269. send_im_param.content = conf_welcome_str
  270. try:
  271. thecall.sendInstantMessage(send_im_param)
  272. except:
  273. pass
  274. # inform others, including self
  275. msg = "[Conf manager] %s has joined" % (thecall.peerUri)
  276. self.addMessage(None, msg)
  277. self._sendInstantMessage(msg, thecall.peerUri)
  278. elif info.state == pj.PJSIP_INV_STATE_DISCONNECTED:
  279. if info.lastStatusCode/100 != 2:
  280. self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.FAILED)
  281. else:
  282. self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.DISCONNECTED)
  283. # reset entry in the callList
  284. try:
  285. idx = self._callList.index(thecall)
  286. if idx >= 0: self._callList[idx] = None
  287. except:
  288. pass
  289. self.addMessage(None, "Call to '%s' disconnected: %s" % (thecall.peerUri, info.lastReason))
  290. # kick the disconnected participant, but the last (avoid zombie chat)
  291. if not self.isPrivate():
  292. self.kickParticipant(ParseSipUri(thecall.peerUri))
  293. # inform others, including self
  294. msg = "[Conf manager] %s has left" % (thecall.peerUri)
  295. self.addMessage(None, msg)
  296. self._sendInstantMessage(msg, thecall.peerUri)
  297. def updateCallMediaState(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()
  300. med_idx = self._getActiveMediaIdx(thecall)
  301. if (med_idx < 0):
  302. self._gui.audioSetStatsText(thecall.peerUri, 'No active media')
  303. return
  304. si = thecall.getStreamInfo(med_idx)
  305. dir_str = ''
  306. if si.dir == 0:
  307. dir_str = 'inactive'
  308. else:
  309. if si.dir & pj.PJMEDIA_DIR_ENCODING:
  310. dir_str += 'send '
  311. if si.dir & pj.PJMEDIA_DIR_DECODING:
  312. dir_str += 'receive '
  313. stats_str = "Direction : %s\n" % (dir_str)
  314. stats_str += "Audio codec : %s (%sHz)" % (si.codecName, si.codecClockRate)
  315. self._gui.audioSetStatsText(thecall.peerUri, stats_str)
  316. m = pj.AudioMedia.typecastFromMedia(thecall.getMedia(med_idx))
  317. # make conference
  318. for c in self._callList:
  319. if c == thecall:
  320. continue
  321. med_idx = self._getActiveMediaIdx(c)
  322. if med_idx < 0:
  323. continue
  324. mm = pj.AudioMedia.typecastFromMedia(c.getMedia(med_idx))
  325. m.startTransmit(mm)
  326. mm.startTransmit(m)
  327. # ** callbacks from GUI (ChatObserver implementation) **
  328. # Text
  329. def onSendMessage(self, msg):
  330. self._sendInstantMessage(msg)
  331. def onStartTyping(self):
  332. self._sendTypingIndication(True)
  333. def onStopTyping(self):
  334. self._sendTypingIndication(False)
  335. # Audio
  336. def onHangup(self, peer_uri_str):
  337. c = self._getCallFromUriStr(peer_uri_str, "onHangup()")
  338. if not c: return
  339. call_param = pj.CallOpParam()
  340. c.hangup(call_param)
  341. def onHold(self, peer_uri_str):
  342. c = self._getCallFromUriStr(peer_uri_str, "onHold()")
  343. if not c: return
  344. call_param = pj.CallOpParam()
  345. c.setHold(call_param)
  346. def onUnhold(self, peer_uri_str):
  347. c = self._getCallFromUriStr(peer_uri_str, "onUnhold()")
  348. if not c: return
  349. call_param = pj.CallOpParam()
  350. call_param.opt.audioCount = 1
  351. call_param.opt.videoCount = 0
  352. call_param.opt.flag |= pj.PJSUA_CALL_UNHOLD
  353. c.reinvite(call_param)
  354. def onRxMute(self, peer_uri_str, mute):
  355. am = self._getAudioMediaFromUriStr(peer_uri_str)
  356. if not am: return
  357. if mute:
  358. am.stopTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
  359. self.addMessage(None, "Muted audio from '%s'" % (peer_uri_str))
  360. else:
  361. am.startTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
  362. self.addMessage(None, "Unmuted audio from '%s'" % (peer_uri_str))
  363. def onRxVol(self, peer_uri_str, vol_pct):
  364. am = self._getAudioMediaFromUriStr(peer_uri_str)
  365. if not am: return
  366. # pjsua volume range = 0:mute, 1:no adjustment, 2:100% louder
  367. am.adjustRxLevel(vol_pct/50.0)
  368. self.addMessage(None, "Adjusted volume level audio from '%s'" % (peer_uri_str))
  369. def onTxMute(self, peer_uri_str, mute):
  370. am = self._getAudioMediaFromUriStr(peer_uri_str)
  371. if not am: return
  372. if mute:
  373. ep.Endpoint.instance.audDevManager().getCaptureDevMedia().stopTransmit(am)
  374. self.addMessage(None, "Muted audio to '%s'" % (peer_uri_str))
  375. else:
  376. ep.Endpoint.instance.audDevManager().getCaptureDevMedia().startTransmit(am)
  377. self.addMessage(None, "Unmuted audio to '%s'" % (peer_uri_str))
  378. # Chat room
  379. def onAddParticipant(self):
  380. buds = []
  381. dlg = AddParticipantDlg(None, self._app, buds)
  382. if dlg.doModal():
  383. for bud in buds:
  384. uri = ParseSipUri(bud.cfg.uri)
  385. self.addParticipant(uri)
  386. if not self.isPrivate():
  387. self.startCall()
  388. def onStartAudio(self):
  389. self.startCall()
  390. def onStopAudio(self):
  391. self.stopCall()
  392. def onCloseWindow(self):
  393. self.stopCall()
  394. # will remove entry from list eventually destroy this chat?
  395. if self in self._acc.chatList: self._acc.chatList.remove(self)
  396. self._app.updateWindowMenu()
  397. # destroy GUI
  398. self._gui.destroy()
  399. class AddParticipantDlg(tk.Toplevel):
  400. """
  401. List of buddies
  402. """
  403. def __init__(self, parent, app, bud_list):
  404. tk.Toplevel.__init__(self, parent)
  405. self.title('Add participants..')
  406. self.transient(parent)
  407. self.parent = parent
  408. self._app = app
  409. self.buddyList = bud_list
  410. self.isOk = False
  411. self.createWidgets()
  412. def doModal(self):
  413. if self.parent:
  414. self.parent.wait_window(self)
  415. else:
  416. self.wait_window(self)
  417. return self.isOk
  418. def createWidgets(self):
  419. # buddy list
  420. list_frame = ttk.Frame(self)
  421. list_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=1, padx=20, pady=20)
  422. #scrl = ttk.Scrollbar(self, orient=tk.VERTICAL, command=list_frame.yview)
  423. #list_frame.config(yscrollcommand=scrl.set)
  424. #scrl.pack(side=tk.RIGHT, fill=tk.Y)
  425. # draw buddy list
  426. self.buddies = []
  427. for acc in self._app.accList:
  428. self.buddies.append((0, acc.cfg.idUri))
  429. for bud in acc.buddyList:
  430. self.buddies.append((1, bud))
  431. self.bud_var = []
  432. for idx,(flag,bud) in enumerate(self.buddies):
  433. self.bud_var.append(tk.IntVar())
  434. if flag==0:
  435. s = ttk.Separator(list_frame, orient=tk.HORIZONTAL)
  436. s.pack(fill=tk.X)
  437. l = tk.Label(list_frame, anchor=tk.W, text="Account '%s':" % (bud))
  438. l.pack(fill=tk.X)
  439. else:
  440. c = tk.Checkbutton(list_frame, anchor=tk.W, text=bud.cfg.uri, variable=self.bud_var[idx])
  441. c.pack(fill=tk.X)
  442. s = ttk.Separator(list_frame, orient=tk.HORIZONTAL)
  443. s.pack(fill=tk.X)
  444. # Ok/cancel buttons
  445. tail_frame = ttk.Frame(self)
  446. tail_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=1)
  447. btnOk = ttk.Button(tail_frame, text='Ok', default=tk.ACTIVE, command=self.onOk)
  448. btnOk.pack(side=tk.LEFT, padx=20, pady=10)
  449. btnCancel = ttk.Button(tail_frame, text='Cancel', command=self.onCancel)
  450. btnCancel.pack(side=tk.RIGHT, padx=20, pady=10)
  451. def onOk(self):
  452. self.buddyList[:] = []
  453. for idx,(flag,bud) in enumerate(self.buddies):
  454. if not flag: continue
  455. if self.bud_var[idx].get() and not (bud in self.buddyList):
  456. self.buddyList.append(bud)
  457. self.isOk = True
  458. self.destroy()
  459. def onCancel(self):
  460. self.destroy()