Mercurial > libervia-backend
comparison libervia/frontends/quick_frontend/quick_chat.py @ 4074:26b7ed2817da
refactoring: rename `sat_frontends` to `libervia.frontends`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 14:12:38 +0200 |
parents | sat_frontends/quick_frontend/quick_chat.py@4b842c1fb686 |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4073:7c5654c54fed | 4074:26b7ed2817da |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # helper class for making a SàT frontend | |
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 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 Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 from libervia.backend.core.i18n import _ | |
20 from libervia.backend.core.log import getLogger | |
21 from libervia.backend.tools.common import data_format | |
22 from libervia.backend.core import exceptions | |
23 from libervia.frontends.quick_frontend import quick_widgets | |
24 from libervia.frontends.quick_frontend.constants import Const as C | |
25 from collections import OrderedDict | |
26 from libervia.frontends.tools import jid | |
27 import time | |
28 | |
29 | |
30 log = getLogger(__name__) | |
31 | |
32 | |
33 ROOM_USER_JOINED = "ROOM_USER_JOINED" | |
34 ROOM_USER_LEFT = "ROOM_USER_LEFT" | |
35 ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT) | |
36 | |
37 # from datetime import datetime | |
38 | |
39 # FIXME: day_format need to be settable (i18n) | |
40 | |
41 | |
42 class Message: | |
43 """Message metadata""" | |
44 | |
45 def __init__( | |
46 self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, | |
47 profile): | |
48 self.parent = parent | |
49 self.profile = profile | |
50 self.uid = uid | |
51 self.timestamp = timestamp | |
52 self.from_jid = from_jid | |
53 self.to_jid = to_jid | |
54 self.message = msg | |
55 self.subject = subject | |
56 self.type = type_ | |
57 self.extra = extra | |
58 self.nick = self.get_nick(from_jid) | |
59 self._status = None | |
60 # own_mess is True if message was sent by profile's jid | |
61 self.own_mess = ( | |
62 (from_jid.resource == self.parent.nick) | |
63 if self.parent.type == C.CHAT_GROUP | |
64 else (from_jid.bare == self.host.profiles[profile].whoami.bare) | |
65 ) | |
66 # is user mentioned here ? | |
67 if self.parent.type == C.CHAT_GROUP and not self.own_mess: | |
68 for m in msg.values(): | |
69 if self.parent.nick.lower() in m.lower(): | |
70 self._mention = True | |
71 break | |
72 self.handle_me() | |
73 self.widgets = set() # widgets linked to this message | |
74 | |
75 def __str__(self): | |
76 return "Message<{mess_type}> [{time}]{nick}> {message}".format( | |
77 mess_type=self.type, | |
78 time=self.time_text, | |
79 nick=self.nick, | |
80 message=self.main_message) | |
81 | |
82 def __contains__(self, item): | |
83 return hasattr(self, item) or item in self.extra | |
84 | |
85 @property | |
86 def host(self): | |
87 return self.parent.host | |
88 | |
89 @property | |
90 def info_type(self): | |
91 return self.extra.get("info_type") | |
92 | |
93 @property | |
94 def mention(self): | |
95 try: | |
96 return self._mention | |
97 except AttributeError: | |
98 return False | |
99 | |
100 @property | |
101 def history(self): | |
102 """True if message come from history""" | |
103 return self.extra.get("history", False) | |
104 | |
105 @property | |
106 def main_message(self): | |
107 """currently displayed message""" | |
108 if self.parent.lang in self.message: | |
109 self.selected_lang = self.parent.lang | |
110 return self.message[self.parent.lang] | |
111 try: | |
112 self.selected_lang = "" | |
113 return self.message[""] | |
114 except KeyError: | |
115 try: | |
116 lang, mess = next(iter(self.message.items())) | |
117 self.selected_lang = lang | |
118 return mess | |
119 except StopIteration: | |
120 if not self.attachments: | |
121 # we may have empty messages if we have attachments | |
122 log.error("Can't find message for uid {}".format(self.uid)) | |
123 return "" | |
124 | |
125 @property | |
126 def main_message_xhtml(self): | |
127 """rich message""" | |
128 xhtml = {k: v for k, v in self.extra.items() if "html" in k} | |
129 if xhtml: | |
130 # FIXME: we only return first found value for now | |
131 return next(iter(xhtml.values())) | |
132 | |
133 @property | |
134 def time_text(self): | |
135 """Return timestamp in a nicely formatted way""" | |
136 # if the message was sent before today, we print the full date | |
137 timestamp = time.localtime(self.timestamp) | |
138 time_format = "%c" if timestamp < self.parent.day_change else "%H:%M" | |
139 return time.strftime(time_format, timestamp) | |
140 | |
141 @property | |
142 def avatar(self): | |
143 """avatar data or None if no avatar is found""" | |
144 entity = self.from_jid | |
145 contact_list = self.host.contact_lists[self.profile] | |
146 try: | |
147 return contact_list.getCache(entity, "avatar") | |
148 except (exceptions.NotFound, KeyError): | |
149 # we don't check the result as the avatar listener will be called | |
150 self.host.bridge.avatar_get(entity, True, self.profile) | |
151 return None | |
152 | |
153 @property | |
154 def encrypted(self): | |
155 return self.extra.get("encrypted", False) | |
156 | |
157 def get_nick(self, entity): | |
158 """Return nick of an entity when possible""" | |
159 contact_list = self.host.contact_lists[self.profile] | |
160 if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED: | |
161 try: | |
162 return self.extra["user_nick"] | |
163 except KeyError: | |
164 log.error("extra data is missing user nick for uid {}".format(self.uid)) | |
165 return "" | |
166 # FIXME: converted get_specials to list for pyjamas | |
167 if self.parent.type == C.CHAT_GROUP or entity in list( | |
168 contact_list.get_specials(C.CONTACT_SPECIAL_GROUP) | |
169 ): | |
170 return entity.resource or "" | |
171 if entity.bare in contact_list: | |
172 | |
173 try: | |
174 nicknames = contact_list.getCache(entity, "nicknames") | |
175 except (exceptions.NotFound, KeyError): | |
176 # we check result as listener will be called | |
177 self.host.bridge.identity_get( | |
178 entity.bare, ["nicknames"], True, self.profile) | |
179 return entity.node or entity | |
180 | |
181 if nicknames: | |
182 return nicknames[0] | |
183 else: | |
184 return ( | |
185 contact_list.getCache(entity, "name", default=None) | |
186 or entity.node | |
187 or entity | |
188 ) | |
189 | |
190 return entity.node or entity | |
191 | |
192 @property | |
193 def status(self): | |
194 return self._status | |
195 | |
196 @status.setter | |
197 def status(self, status): | |
198 if status != self._status: | |
199 self._status = status | |
200 for w in self.widgets: | |
201 w.update({"status": status}) | |
202 | |
203 def handle_me(self): | |
204 """Check if messages starts with "/me " and change them if it is the case | |
205 | |
206 if several messages (different languages) are presents, they all need to start with "/me " | |
207 """ | |
208 # TODO: XHTML-IM /me are not handled | |
209 me = False | |
210 # we need to check /me for every message | |
211 for m in self.message.values(): | |
212 if m.startswith("/me "): | |
213 me = True | |
214 else: | |
215 me = False | |
216 break | |
217 if me: | |
218 self.type = C.MESS_TYPE_INFO | |
219 self.extra["info_type"] = "me" | |
220 nick = self.nick | |
221 for lang, mess in self.message.items(): | |
222 self.message[lang] = "* " + nick + mess[3:] | |
223 | |
224 @property | |
225 def attachments(self): | |
226 return self.extra.get(C.KEY_ATTACHMENTS) | |
227 | |
228 | |
229 class MessageWidget: | |
230 """Base classe for widgets""" | |
231 # This class does nothing and is only used to have a common ancestor | |
232 | |
233 pass | |
234 | |
235 | |
236 class Occupant: | |
237 """Occupant metadata""" | |
238 | |
239 def __init__(self, parent, data, profile): | |
240 self.parent = parent | |
241 self.profile = profile | |
242 self.nick = data["nick"] | |
243 self._entity = data.get("entity") | |
244 self.affiliation = data["affiliation"] | |
245 self.role = data["role"] | |
246 self.widgets = set() # widgets linked to this occupant | |
247 self._state = None | |
248 | |
249 @property | |
250 def data(self): | |
251 """reconstruct data dict from attributes""" | |
252 data = {} | |
253 data["nick"] = self.nick | |
254 if self._entity is not None: | |
255 data["entity"] = self._entity | |
256 data["affiliation"] = self.affiliation | |
257 data["role"] = self.role | |
258 return data | |
259 | |
260 @property | |
261 def jid(self): | |
262 """jid in the room""" | |
263 return jid.JID("{}/{}".format(self.parent.target.bare, self.nick)) | |
264 | |
265 @property | |
266 def real_jid(self): | |
267 """real jid if known else None""" | |
268 return self._entity | |
269 | |
270 @property | |
271 def host(self): | |
272 return self.parent.host | |
273 | |
274 @property | |
275 def state(self): | |
276 return self._state | |
277 | |
278 @state.setter | |
279 def state(self, new_state): | |
280 if new_state != self._state: | |
281 self._state = new_state | |
282 for w in self.widgets: | |
283 w.update({"state": new_state}) | |
284 | |
285 def update(self, update_dict=None): | |
286 for w in self.widgets: | |
287 w.update(update_dict) | |
288 | |
289 | |
290 class QuickChat(quick_widgets.QuickWidget): | |
291 visible_states = ["chat_state"] # FIXME: to be removed, used only in quick_games | |
292 | |
293 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, | |
294 subject=None, statuses=None, profiles=None): | |
295 """ | |
296 @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for | |
297 chat à la IRC | |
298 """ | |
299 self.lang = "" # default language to use for messages | |
300 quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles) | |
301 assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP) | |
302 self.current_target = target | |
303 self.type = type_ | |
304 self.encrypted = False # True if this session is currently encrypted | |
305 self._locked = False | |
306 # True when resync is in progress, avoid resynchronising twice when resync is called | |
307 # and history is still being updated. For internal use only | |
308 self._resync_lock = False | |
309 self.set_locked() | |
310 if type_ == C.CHAT_GROUP: | |
311 if target.resource: | |
312 raise exceptions.InternalError( | |
313 "a group chat entity can't have a resource" | |
314 ) | |
315 if nick is None: | |
316 raise exceptions.InternalError("nick must not be None for group chat") | |
317 | |
318 self.nick = nick | |
319 self.occupants = {} | |
320 self.set_occupants(occupants) | |
321 else: | |
322 if occupants is not None or nick is not None: | |
323 raise exceptions.InternalError( | |
324 "only group chat can have occupants or nick" | |
325 ) | |
326 self.messages = OrderedDict() # key: uid, value: Message instance | |
327 self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame | |
328 self.subject = subject | |
329 self.statuses = set(statuses or []) | |
330 lt = time.localtime() | |
331 self.day_change = ( | |
332 lt.tm_year, | |
333 lt.tm_mon, | |
334 lt.tm_mday, | |
335 0, | |
336 0, | |
337 0, | |
338 lt.tm_wday, | |
339 lt.tm_yday, | |
340 lt.tm_isdst, | |
341 ) # struct_time of day changing time | |
342 if self.host.AVATARS_HANDLER: | |
343 self.host.addListener("avatar", self.on_avatar, profiles) | |
344 | |
345 def set_locked(self): | |
346 """Set locked flag | |
347 | |
348 To be set when we are waiting for history/search | |
349 """ | |
350 # FIXME: we don't use getter/setter here because of pyjamas | |
351 # TODO: use proper getter/setter once we get rid of pyjamas | |
352 if self._locked: | |
353 log.warning("{wid} is already locked!".format(wid=self)) | |
354 return | |
355 self._locked = True | |
356 # message_new signals are cached when locked | |
357 self._cache = OrderedDict() | |
358 log.debug("{wid} is now locked".format(wid=self)) | |
359 | |
360 def set_unlocked(self): | |
361 if not self._locked: | |
362 log.debug("{wid} was already unlocked".format(wid=self)) | |
363 return | |
364 self._locked = False | |
365 for uid, data in self._cache.items(): | |
366 if uid not in self.messages: | |
367 self.message_new(*data) | |
368 else: | |
369 log.debug("discarding message already in history: {data}, ".format(data=data)) | |
370 del self._cache | |
371 log.debug("{wid} is now unlocked".format(wid=self)) | |
372 | |
373 def post_init(self): | |
374 """Method to be called by frontend after widget is initialised | |
375 | |
376 handle the display of history and subject | |
377 """ | |
378 self.history_print(profile=self.profile) | |
379 if self.subject is not None: | |
380 self.set_subject(self.subject) | |
381 if self.host.ENCRYPTION_HANDLERS: | |
382 self.get_encryption_state() | |
383 | |
384 def on_delete(self): | |
385 if self.host.AVATARS_HANDLER: | |
386 self.host.removeListener("avatar", self.on_avatar) | |
387 | |
388 @property | |
389 def contact_list(self): | |
390 return self.host.contact_lists[self.profile] | |
391 | |
392 @property | |
393 def message_widgets_rev(self): | |
394 """Return the history of MessageWidget in reverse chronological order | |
395 | |
396 Must be implemented by frontend | |
397 """ | |
398 raise NotImplementedError | |
399 | |
400 ## synchornisation handling ## | |
401 | |
402 @quick_widgets.QuickWidget.sync.setter | |
403 def sync(self, state): | |
404 quick_widgets.QuickWidget.sync.fset(self, state) | |
405 if not state: | |
406 self.set_locked() | |
407 | |
408 def _resync_complete(self): | |
409 self.sync = True | |
410 self._resync_lock = False | |
411 | |
412 def occupants_clear(self): | |
413 """Remove all occupants | |
414 | |
415 Must be overridden by frontends to clear their own representations of occupants | |
416 """ | |
417 self.occupants.clear() | |
418 | |
419 def resync(self): | |
420 if self._resync_lock: | |
421 return | |
422 self._resync_lock = True | |
423 log.debug("resynchronising {self}".format(self=self)) | |
424 for mess in reversed(list(self.messages.values())): | |
425 if mess.type == C.MESS_TYPE_INFO: | |
426 continue | |
427 last_message = mess | |
428 break | |
429 else: | |
430 # we have no message yet, we can get normal history | |
431 self.history_print(callback=self._resync_complete, profile=self.profile) | |
432 return | |
433 if self.type == C.CHAT_GROUP: | |
434 self.occupants_clear() | |
435 self.host.bridge.muc_occupants_get( | |
436 str(self.target), self.profile, callback=self.update_occupants, | |
437 errback=log.error) | |
438 self.history_print( | |
439 size=C.HISTORY_LIMIT_NONE, | |
440 filters={'timestamp_start': last_message.timestamp}, | |
441 callback=self._resync_complete, | |
442 profile=self.profile) | |
443 | |
444 ## Widget management ## | |
445 | |
446 def __str__(self): | |
447 return "Chat Widget [target: {}, type: {}, profile: {}]".format( | |
448 self.target, self.type, self.profile | |
449 ) | |
450 | |
451 @staticmethod | |
452 def get_widget_hash(target, profiles): | |
453 profile = list(profiles)[0] | |
454 return profile + "\n" + str(target.bare) | |
455 | |
456 @staticmethod | |
457 def get_private_hash(target, profile): | |
458 """Get unique hash for private conversations | |
459 | |
460 This method should be used with force_hash to get unique widget for private MUC conversations | |
461 """ | |
462 return (str(profile), target) | |
463 | |
464 def add_target(self, target): | |
465 super(QuickChat, self).add_target(target) | |
466 if target.resource: | |
467 self.current_target = ( | |
468 target | |
469 ) # FIXME: tmp, must use resource priority throught contactList instead | |
470 | |
471 def recreate_args(self, args, kwargs): | |
472 """copy important attribute for a new widget""" | |
473 kwargs["type_"] = self.type | |
474 if self.type == C.CHAT_GROUP: | |
475 kwargs["occupants"] = {o.nick: o.data for o in self.occupants.values()} | |
476 kwargs["subject"] = self.subject | |
477 try: | |
478 kwargs["nick"] = self.nick | |
479 except AttributeError: | |
480 pass | |
481 | |
482 def on_private_created(self, widget): | |
483 """Method called when a new widget for private conversation (MUC) is created""" | |
484 raise NotImplementedError | |
485 | |
486 def get_or_create_private_widget(self, entity): | |
487 """Create a widget for private conversation, or get it if it already exists | |
488 | |
489 @param entity: full jid of the target | |
490 """ | |
491 return self.host.widgets.get_or_create_widget( | |
492 QuickChat, | |
493 entity, | |
494 type_=C.CHAT_ONE2ONE, | |
495 force_hash=self.get_private_hash(self.profile, entity), | |
496 on_new_widget=self.on_private_created, | |
497 profile=self.profile, | |
498 ) # we force hash to have a new widget, not this one again | |
499 | |
500 @property | |
501 def target(self): | |
502 if self.type == C.CHAT_GROUP: | |
503 return self.current_target.bare | |
504 return self.current_target | |
505 | |
506 ## occupants ## | |
507 | |
508 def set_occupants(self, occupants): | |
509 """Set the whole list of occupants""" | |
510 assert len(self.occupants) == 0 | |
511 for nick, data in occupants.items(): | |
512 # XXX: this log is disabled because it's really too verbose | |
513 # but kept commented as it may be useful for debugging | |
514 # log.debug(u"adding occupant {nick} to {room}".format( | |
515 # nick=nick, room=self.target)) | |
516 self.occupants[nick] = Occupant(self, data, self.profile) | |
517 | |
518 def update_occupants(self, occupants): | |
519 """Update occupants list | |
520 | |
521 In opposition to set_occupants, this only add missing occupants and remove | |
522 occupants who have left | |
523 """ | |
524 # FIXME: occupants with modified status are not handled | |
525 local_occupants = set(self.occupants) | |
526 updated_occupants = set(occupants) | |
527 left_occupants = local_occupants - updated_occupants | |
528 joined_occupants = updated_occupants - local_occupants | |
529 log.debug("updating occupants for {room}:\n" | |
530 "left: {left_occupants}\n" | |
531 "joined: {joined_occupants}" | |
532 .format(room=self.target, | |
533 left_occupants=", ".join(left_occupants), | |
534 joined_occupants=", ".join(joined_occupants))) | |
535 for nick in left_occupants: | |
536 self.removeUser(occupants[nick]) | |
537 for nick in joined_occupants: | |
538 self.addUser(occupants[nick]) | |
539 | |
540 def addUser(self, occupant_data): | |
541 """Add user if it is not in the group list""" | |
542 occupant = Occupant(self, occupant_data, self.profile) | |
543 self.occupants[occupant.nick] = occupant | |
544 return occupant | |
545 | |
546 def removeUser(self, occupant_data): | |
547 """Remove a user from the group list""" | |
548 nick = occupant_data["nick"] | |
549 try: | |
550 occupant = self.occupants.pop(nick) | |
551 except KeyError: | |
552 log.warning("Trying to remove an unknown occupant: {}".format(nick)) | |
553 else: | |
554 return occupant | |
555 | |
556 def set_user_nick(self, nick): | |
557 """Set the nick of the user, usefull for e.g. change the color of the user""" | |
558 self.nick = nick | |
559 | |
560 def change_user_nick(self, old_nick, new_nick): | |
561 """Change nick of a user in group list""" | |
562 log.info("{old} is now known as {new} in room {room_jid}".format( | |
563 old = old_nick, | |
564 new = new_nick, | |
565 room_jid = self.target)) | |
566 | |
567 ## Messages ## | |
568 | |
569 def manage_message(self, entity, mess_type): | |
570 """Tell if this chat widget manage an entity and message type couple | |
571 | |
572 @param entity (jid.JID): (full) jid of the sending entity | |
573 @param mess_type (str): message type as given by message_new | |
574 @return (bool): True if this Chat Widget manage this couple | |
575 """ | |
576 if self.type == C.CHAT_GROUP: | |
577 if ( | |
578 mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO) | |
579 and self.target == entity.bare | |
580 ): | |
581 return True | |
582 else: | |
583 if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets: | |
584 return True | |
585 return False | |
586 | |
587 def update_history(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile="@NONE@"): | |
588 """Called when history need to be recreated | |
589 | |
590 Remove all message from history then call history_print | |
591 Must probably be overriden by frontend to clear widget | |
592 @param size (int): number of messages | |
593 @param filters (str): patterns to filter the history results | |
594 @param profile (str): %(doc_profile)s | |
595 """ | |
596 self.set_locked() | |
597 self.messages.clear() | |
598 self.history_print(size, filters, profile=profile) | |
599 | |
600 def _on_history_printed(self): | |
601 """Method called when history is printed (or failed) | |
602 | |
603 unlock the widget, and can be used to refresh or scroll down | |
604 the focus after the history is printed | |
605 """ | |
606 self.set_unlocked() | |
607 | |
608 def history_print(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, callback=None, | |
609 profile="@NONE@"): | |
610 """Print the current history | |
611 | |
612 Note: self.set_unlocked will be called once history is printed | |
613 @param size (int): number of messages | |
614 @param search (str): pattern to filter the history results | |
615 @param callback(callable, None): method to call when history has been printed | |
616 @param profile (str): %(doc_profile)s | |
617 """ | |
618 if filters is None: | |
619 filters = {} | |
620 if size == 0: | |
621 log.debug("Empty history requested, skipping") | |
622 self._on_history_printed() | |
623 return | |
624 log_msg = _("now we print the history") | |
625 if size != C.HISTORY_LIMIT_DEFAULT: | |
626 log_msg += _(" ({} messages)".format(size)) | |
627 log.debug(log_msg) | |
628 | |
629 if self.type == C.CHAT_ONE2ONE: | |
630 special = self.host.contact_lists[self.profile].getCache( | |
631 self.target, C.CONTACT_SPECIAL, create_if_not_found=True, default=None | |
632 ) | |
633 if special == C.CONTACT_SPECIAL_GROUP: | |
634 # we have a private conversation | |
635 # so we need full jid for the history | |
636 # (else we would get history from group itself) | |
637 # and to filter out groupchat message | |
638 target = self.target | |
639 filters["not_types"] = C.MESS_TYPE_GROUPCHAT | |
640 else: | |
641 target = self.target.bare | |
642 else: | |
643 # groupchat | |
644 target = self.target.bare | |
645 # FIXME: info not handled correctly | |
646 filters["types"] = C.MESS_TYPE_GROUPCHAT | |
647 | |
648 self.history_filters = filters | |
649 | |
650 def _history_get_cb(history): | |
651 # day_format = "%A, %d %b %Y" # to display the day change | |
652 # previous_day = datetime.now().strftime(day_format) | |
653 # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format) | |
654 # if previous_day != message_day: | |
655 # self.print_day_change(message_day) | |
656 # previous_day = message_day | |
657 for data in history: | |
658 uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data | |
659 from_jid = jid.JID(from_jid) | |
660 to_jid = jid.JID(to_jid) | |
661 extra = data_format.deserialise(extra_s) | |
662 # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or | |
663 # (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)): | |
664 # continue | |
665 extra["history"] = True | |
666 self.messages[uid] = Message( | |
667 self, | |
668 uid, | |
669 timestamp, | |
670 from_jid, | |
671 to_jid, | |
672 message, | |
673 subject, | |
674 type_, | |
675 extra, | |
676 profile, | |
677 ) | |
678 self._on_history_printed() | |
679 if callback is not None: | |
680 callback() | |
681 | |
682 def _history_get_eb(err): | |
683 log.error(_("Can't get history: {}").format(err)) | |
684 self._on_history_printed() | |
685 if callback is not None: | |
686 callback() | |
687 | |
688 self.host.bridge.history_get( | |
689 str(self.host.profiles[profile].whoami.bare), | |
690 str(target), | |
691 size, | |
692 True, | |
693 {k: str(v) for k,v in filters.items()}, | |
694 profile, | |
695 callback=_history_get_cb, | |
696 errback=_history_get_eb, | |
697 ) | |
698 | |
699 def message_encryption_get_cb(self, session_data): | |
700 if session_data: | |
701 session_data = data_format.deserialise(session_data) | |
702 self.message_encryption_started(session_data) | |
703 | |
704 def message_encryption_get_eb(self, failure_): | |
705 log.error(_("Can't get encryption state: {reason}").format(reason=failure_)) | |
706 | |
707 def get_encryption_state(self): | |
708 """Retrieve encryption state with current target. | |
709 | |
710 Once state is retrieved, default message_encryption_started will be called if | |
711 suitable | |
712 """ | |
713 if self.type == C.CHAT_GROUP: | |
714 return | |
715 self.host.bridge.message_encryption_get(str(self.target.bare), self.profile, | |
716 callback=self.message_encryption_get_cb, | |
717 errback=self.message_encryption_get_eb) | |
718 | |
719 | |
720 def message_new(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, | |
721 profile): | |
722 if self._locked: | |
723 self._cache[uid] = ( | |
724 uid, | |
725 timestamp, | |
726 from_jid, | |
727 to_jid, | |
728 msg, | |
729 subject, | |
730 type_, | |
731 extra, | |
732 profile, | |
733 ) | |
734 return | |
735 | |
736 if ((not msg and not subject and not extra[C.KEY_ATTACHMENTS] | |
737 and type_ != C.MESS_TYPE_INFO)): | |
738 log.warning("Received an empty message for uid {}".format(uid)) | |
739 return | |
740 | |
741 if self.type == C.CHAT_GROUP: | |
742 if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT: | |
743 # we have a private message, we forward it to a private conversation | |
744 # widget | |
745 chat_widget = self.get_or_create_private_widget(to_jid) | |
746 chat_widget.message_new( | |
747 uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile | |
748 ) | |
749 return | |
750 if type_ == C.MESS_TYPE_INFO: | |
751 try: | |
752 info_type = extra["info_type"] | |
753 except KeyError: | |
754 pass | |
755 else: | |
756 user_data = { | |
757 k[5:]: v for k, v in extra.items() if k.startswith("user_") | |
758 } | |
759 if info_type == ROOM_USER_JOINED: | |
760 self.addUser(user_data) | |
761 elif info_type == ROOM_USER_LEFT: | |
762 self.removeUser(user_data) | |
763 | |
764 message = Message( | |
765 self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile | |
766 ) | |
767 self.messages[uid] = message | |
768 | |
769 if "received_timestamp" in extra: | |
770 log.warning("Delayed message received after history, this should not happen") | |
771 self.create_message(message) | |
772 | |
773 def message_encryption_started(self, session_data): | |
774 self.encrypted = True | |
775 log.debug(_("message encryption started with {target} using {encryption}").format( | |
776 target=self.target, encryption=session_data['name'])) | |
777 | |
778 def message_encryption_stopped(self, session_data): | |
779 self.encrypted = False | |
780 log.debug(_("message encryption stopped with {target} (was using {encryption})") | |
781 .format(target=self.target, encryption=session_data['name'])) | |
782 | |
783 def create_message(self, message, append=False): | |
784 """Must be implemented by frontend to create and show a new message widget | |
785 | |
786 This is only called on message_new, not on history. | |
787 You need to override history_print to handle the later | |
788 @param message(Message): message data | |
789 """ | |
790 raise NotImplementedError | |
791 | |
792 def is_user_moved(self, message): | |
793 """Return True if message is a user left/joined message | |
794 | |
795 @param message(Message): message to check | |
796 @return (bool): True is message is user moved info message | |
797 """ | |
798 if message.type != C.MESS_TYPE_INFO: | |
799 return False | |
800 try: | |
801 info_type = message.extra["info_type"] | |
802 except KeyError: | |
803 return False | |
804 else: | |
805 return info_type in ROOM_USER_MOVED | |
806 | |
807 def handle_user_moved(self, message): | |
808 """Check if this message is a UserMoved one, and merge it when possible | |
809 | |
810 "merge it" means that info message indicating a user joined/left will be | |
811 grouped if no other non-info messages has been sent since | |
812 @param message(Message): message to check | |
813 @return (bool): True if this message has been merged | |
814 if True, a new MessageWidget must not be created and appended to history | |
815 """ | |
816 if self.is_user_moved(message): | |
817 for wid in self.message_widgets_rev: | |
818 # we merge in/out messages if no message was sent meanwhile | |
819 if not isinstance(wid, MessageWidget): | |
820 continue | |
821 elif wid.mess_data.type != C.MESS_TYPE_INFO: | |
822 return False | |
823 elif ( | |
824 wid.info_type in ROOM_USER_MOVED | |
825 and wid.mess_data.nick == message.nick | |
826 ): | |
827 try: | |
828 count = wid.reentered_count | |
829 except AttributeError: | |
830 count = wid.reentered_count = 1 | |
831 nick = wid.mess_data.nick | |
832 if message.info_type == ROOM_USER_LEFT: | |
833 wid.message = _("<= {nick} has left the room ({count})").format( | |
834 nick=nick, count=count | |
835 ) | |
836 else: | |
837 wid.message = _( | |
838 "<=> {nick} re-entered the room ({count})" | |
839 ).format(nick=nick, count=count) | |
840 wid.reentered_count += 1 | |
841 return True | |
842 return False | |
843 | |
844 def print_day_change(self, day): | |
845 """Display the day on a new line. | |
846 | |
847 @param day(unicode): day to display (or not if this method is not overwritten) | |
848 """ | |
849 # FIXME: not called anymore after refactoring | |
850 pass | |
851 | |
852 ## Room ## | |
853 | |
854 def set_subject(self, subject): | |
855 """Set title for a group chat""" | |
856 if self.type != C.CHAT_GROUP: | |
857 raise exceptions.InternalError( | |
858 "trying to set subject for a non group chat window" | |
859 ) | |
860 self.subject = subject | |
861 | |
862 def change_subject(self, new_subject): | |
863 """Change the subject of the room | |
864 | |
865 This change the subject on the room itself (i.e. via XMPP), | |
866 while set_subject change the subject of this widget | |
867 """ | |
868 self.host.bridge.muc_subject(str(self.target), new_subject, self.profile) | |
869 | |
870 def add_game_panel(self, widget): | |
871 """Insert a game panel to this Chat dialog. | |
872 | |
873 @param widget (Widget): the game panel | |
874 """ | |
875 raise NotImplementedError | |
876 | |
877 def remove_game_panel(self, widget): | |
878 """Remove the game panel from this Chat dialog. | |
879 | |
880 @param widget (Widget): the game panel | |
881 """ | |
882 raise NotImplementedError | |
883 | |
884 def update(self, entity=None): | |
885 """Update one or all entities. | |
886 | |
887 @param entity (jid.JID): entity to update | |
888 """ | |
889 # FIXME: to remove ? | |
890 raise NotImplementedError | |
891 | |
892 ## events ## | |
893 | |
894 def on_chat_state(self, from_jid, state, profile): | |
895 """A chat state has been received""" | |
896 if self.type == C.CHAT_GROUP: | |
897 nick = from_jid.resource | |
898 try: | |
899 self.occupants[nick].state = state | |
900 except KeyError: | |
901 log.warning( | |
902 "{nick} not found in {room}, ignoring new chat state".format( | |
903 nick=nick, room=self.target.bare | |
904 ) | |
905 ) | |
906 | |
907 def on_message_state(self, uid, status, profile): | |
908 try: | |
909 mess_data = self.messages[uid] | |
910 except KeyError: | |
911 pass | |
912 else: | |
913 mess_data.status = status | |
914 | |
915 def on_avatar(self, entity, avatar_data, profile): | |
916 if self.type == C.CHAT_GROUP: | |
917 if entity.bare == self.target: | |
918 try: | |
919 self.occupants[entity.resource].update({"avatar": avatar_data}) | |
920 except KeyError: | |
921 # can happen for a message in history where the | |
922 # entity is not here anymore | |
923 pass | |
924 | |
925 for m in list(self.messages.values()): | |
926 if m.nick == entity.resource: | |
927 for w in m.widgets: | |
928 w.update({"avatar": avatar_data}) | |
929 else: | |
930 if ( | |
931 entity.bare == self.target.bare | |
932 or entity.bare == self.host.profiles[profile].whoami.bare | |
933 ): | |
934 log.info("avatar updated for {}".format(entity)) | |
935 for m in list(self.messages.values()): | |
936 if m.from_jid.bare == entity.bare: | |
937 for w in m.widgets: | |
938 w.update({"avatar": avatar_data}) | |
939 | |
940 | |
941 quick_widgets.register(QuickChat) |