def process_submessage(request: HttpRequest, user_profile: UserProfile, message_id: int=REQ(validator=check_int), msg_type: str=REQ(), content: str=REQ(), ) -> HttpResponse: message, user_message = access_message(user_profile, message_id) if not settings.ALLOW_SUB_MESSAGES: # nocoverage msg = 'Feature not enabled' return json_error(msg) try: data = ujson.loads(content) except Exception: return json_error(_("Invalid json for submessage")) do_add_submessage( sender_id=user_profile.id, message_id=message.id, msg_type=msg_type, content=content, data=data, ) return json_success()
def add_reaction(request: HttpRequest, user_profile: UserProfile, message_id: int, emoji_name: str=REQ(), emoji_code: Optional[str]=REQ(default=None), reaction_type: str=REQ(default="unicode_emoji")) -> HttpResponse: message, user_message = access_message(user_profile, message_id) if emoji_code is None: # The emoji_code argument is only required for rare corner # cases discussed in the long block comment below. For simple # API clients, we allow specifying just the name, and just # look up the code using the current name->code mapping. emoji_code = emoji_name_to_emoji_code(message.sender.realm, emoji_name)[0] if Reaction.objects.filter(user_profile=user_profile, message=message, emoji_code=emoji_code, reaction_type=reaction_type).exists(): raise JsonableError(_("Reaction already exists.")) query = Reaction.objects.filter(message=message, emoji_code=emoji_code, reaction_type=reaction_type) if query.exists(): # If another user has already reacted to this message with # same emoji code, we treat the new reaction as a vote for the # existing reaction. So the emoji name used by that earlier # reaction takes precendence over whatever was passed in this # request. This is necessary to avoid a message having 2 # "different" emoji reactions with the same emoji code (and # thus same image) on the same message, which looks ugly. # # In this "voting for an existing reaction" case, we shouldn't # check whether the emoji code and emoji name match, since # it's possible that the (emoji_type, emoji_name, emoji_code) # triple for this existing rection xmay not pass validation # now (e.g. because it is for a realm emoji that has been # since deactivated). We still want to allow users to add a # vote any old reaction they see in the UI even if that is a # deactivated custom emoji, so we just use the emoji name from # the existing reaction with no further validation. emoji_name = query.first().emoji_name else: # Otherwise, use the name provided in this request, but verify # it is valid in the user's realm (e.g. not a deactivated # realm emoji). check_emoji_request(message.sender.realm, emoji_name, emoji_code, reaction_type) if user_message is None: create_historical_message(user_profile, message) do_add_reaction(user_profile, message, emoji_name, emoji_code, reaction_type) return json_success()
def get_message_edit_history(request, user_profile, message_id=REQ(converter=to_non_negative_int)): # type: (HttpRequest, UserProfile, int) -> HttpResponse message, ignored_user_message = access_message(user_profile, message_id) # Extract the message edit history from the message message_edit_history = ujson.loads(message.edit_history) # Fill in all the extra data that will make it usable fill_edit_history_entries(message_edit_history, message) return json_success({"message_history": reversed(message_edit_history)})
def remove_reaction_legacy(request: HttpRequest, user_profile: UserProfile, message_id: int, emoji_name: Text) -> HttpResponse: # access_message will throw a JsonableError exception if the user # cannot see the message (e.g. for messages to private streams). message = access_message(user_profile, message_id)[0] # We could probably just make this check be a try/except for the # IntegrityError from it already existing, but this is a bit cleaner. if not Reaction.objects.filter(user_profile=user_profile, message=message, emoji_name=emoji_name).exists(): raise JsonableError(_("Reaction does not exist")) do_remove_reaction_legacy(user_profile, message, emoji_name) return json_success()
def handle_remove_push_notification(user_profile_id: int, message_id: int) -> None: """This should be called when a message that had previously had a mobile push executed is read. This triggers a mobile push notifica mobile app when the message is read on the server, to remove the message from the notification. """ user_profile = get_user_profile_by_id(user_profile_id) message, user_message = access_message(user_profile, message_id) if not settings.SEND_REMOVE_PUSH_NOTIFICATIONS: # It's a little annoying that we duplicate this flag-clearing # code (also present below), but this block is scheduled to be # removed in a few weeks, once the app has supported the # feature for long enough. user_message.flags.active_mobile_push_notification = False user_message.save(update_fields=["flags"]) return gcm_payload = get_common_payload(message) gcm_payload.update({ 'event': 'remove', 'zulip_message_id': message_id, # message_id is reserved for CCS }) if uses_notification_bouncer(): try: send_notifications_to_bouncer(user_profile_id, {}, gcm_payload) except requests.ConnectionError: # nocoverage def failure_processor(event: Dict[str, Any]) -> None: logging.warning( "Maximum retries exceeded for trigger:%s event:push_notification" % ( event['user_profile_id'])) return android_devices = list(PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)) if android_devices: send_android_push_notification(android_devices, gcm_payload) user_message.flags.active_mobile_push_notification = False user_message.save(update_fields=["flags"])
def remove_reaction_backend(request, user_profile, message_id, emoji_name): # type: (HttpRequest, UserProfile, int, text_type) -> HttpResponse # access_message will throw a JsonableError exception if the user # cannot see the message (e.g. for messages to private streams). message = access_message(user_profile, message_id)[0] existing_emojis = set(message.sender.realm.get_emoji().keys()) or set(emoji_list) if emoji_name not in existing_emojis: raise JsonableError(_("Emoji '%s' does not exist" % (emoji_name,))) # We could probably just make this check be a try/except for the # IntegrityError from it already existing, but this is a bit cleaner. if not Reaction.objects.filter(user_profile=user_profile, message=message, emoji_name=emoji_name).exists(): raise JsonableError(_("Reaction does not exist")) do_remove_reaction(user_profile, message, emoji_name) return json_success()
def process_submessage(request: HttpRequest, user_profile: UserProfile, message_id: int=REQ(validator=check_int), msg_type: str=REQ(), content: str=REQ(), ) -> HttpResponse: message, user_message = access_message(user_profile, message_id) try: ujson.loads(content) except Exception: return json_error(_("Invalid json for submessage")) do_add_submessage( realm=user_profile.realm, sender_id=user_profile.id, message_id=message.id, msg_type=msg_type, content=content, ) return json_success()
def remove_reaction(request: HttpRequest, user_profile: UserProfile, message_id: int, emoji_code: str=REQ(), reaction_type: str=REQ(default="unicode_emoji")) -> HttpResponse: message, user_message = access_message(user_profile, message_id) if not Reaction.objects.filter(user_profile=user_profile, message=message, emoji_code=emoji_code, reaction_type=reaction_type).exists(): raise JsonableError(_("Reaction doesn't exist.")) # Unlike adding reactions, while deleting a reaction, we don't # check whether the provided (emoji_type, emoji_code) pair is # valid in this realm. Since there's a row in the database, we # know it was valid when the user added their reaction in the # first place, so it is safe to just remove the reaction if it # exists. And the (reaction_type, emoji_code) pair may no longer be # valid in legitimate situations (e.g. if a realm emoji was # deactivated by an administrator in the meantime). do_remove_reaction(user_profile, message, emoji_code, reaction_type) return json_success()
def add_reaction_legacy(request: HttpRequest, user_profile: UserProfile, message_id: int, emoji_name: Text) -> HttpResponse: # access_message will throw a JsonableError exception if the user # cannot see the message (e.g. for messages to private streams). message, user_message = access_message(user_profile, message_id) check_valid_emoji(message.sender.realm, emoji_name) # We could probably just make this check be a try/except for the # IntegrityError from it already existing, but this is a bit cleaner. if Reaction.objects.filter(user_profile=user_profile, message=message, emoji_name=emoji_name).exists(): raise JsonableError(_("Reaction already exists")) if user_message is None: create_historical_message(user_profile, message) do_add_reaction_legacy(user_profile, message, emoji_name) return json_success()
def remove_reaction(request: HttpRequest, user_profile: UserProfile, message_id: int, emoji_name: Optional[str]=REQ(default=None), emoji_code: Optional[str]=REQ(default=None), reaction_type: str=REQ(default="unicode_emoji")) -> HttpResponse: message, user_message = access_message(user_profile, message_id) if emoji_code is None: if emoji_name is None: raise JsonableError(_('At least one of the following arguments ' 'must be present: emoji_name, emoji_code')) # A correct full Zulip client implementation should always # pass an emoji_code, because of the corner cases discussed in # the long block comments elsewhere in this file. However, to # make it easy for simple API clients to use the reactions API # without needing the mapping between emoji names and codes, # we allow instead passing the emoji_name and looking up the # corresponding code using the current data. emoji_code = emoji_name_to_emoji_code(message.sender.realm, emoji_name)[0] if not Reaction.objects.filter(user_profile=user_profile, message=message, emoji_code=emoji_code, reaction_type=reaction_type).exists(): raise JsonableError(_("Reaction doesn't exist.")) # Unlike adding reactions, while deleting a reaction, we don't # check whether the provided (emoji_type, emoji_code) pair is # valid in this realm. Since there's a row in the database, we # know it was valid when the user added their reaction in the # first place, so it is safe to just remove the reaction if it # exists. And the (reaction_type, emoji_code) pair may no longer be # valid in legitimate situations (e.g. if a realm emoji was # deactivated by an administrator in the meantime). do_remove_reaction(user_profile, message, emoji_code, reaction_type) return json_success()
def handle_remove_push_notification(user_profile_id: int, message_id: int) -> None: """This should be called when a message that had previously had a mobile push executed is read. This triggers a mobile push notifica mobile app when the message is read on the server, to remove the message from the notification. """ user_profile = get_user_profile_by_id(user_profile_id) message, user_message = access_message(user_profile, message_id) gcm_payload = get_common_payload(message) gcm_payload.update({ 'event': 'remove', 'zulip_message_id': message_id, # message_id is reserved for CCS }) gcm_options = {'priority': 'normal'} # type: Dict[str, Any] if uses_notification_bouncer(): try: send_notifications_to_bouncer(user_profile_id, {}, gcm_payload, gcm_options) except requests.ConnectionError: # nocoverage def failure_processor(event: Dict[str, Any]) -> None: logger.warning( "Maximum retries exceeded for trigger:%s event:push_notification" % ( event['user_profile_id'])) else: android_devices = list(PushDeviceToken.objects.filter( user=user_profile, kind=PushDeviceToken.GCM)) if android_devices: send_android_push_notification(android_devices, gcm_payload, gcm_options) user_message.flags.active_mobile_push_notification = False user_message.save(update_fields=["flags"])
def json_fetch_raw_message(request, user_profile, message_id=REQ(converter=to_non_negative_int)): # type: (HttpRequest, UserProfile, int) -> HttpResponse (message, user_message) = access_message(user_profile, message_id) return json_success({"raw_content": message.content})
def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None: """ missed_message is the event received by the zerver.worker.queue_processors.PushNotificationWorker.consume function. """ if not push_notifications_enabled(): return user_profile = get_user_profile_by_id(user_profile_id) if not (receives_offline_push_notifications(user_profile) or receives_online_notifications(user_profile)): return user_profile = get_user_profile_by_id(user_profile_id) try: (message, user_message) = access_message(user_profile, missed_message['message_id']) except JsonableError: if ArchivedMessage.objects.filter(id=missed_message['message_id']).exists(): # If the cause is a race with the message being deleted, # that's normal and we have no need to log an error. return logging.error("Unexpected message access failure handling push notifications: %s %s" % ( user_profile.id, missed_message['message_id'])) return if user_message is not None: # If the user has read the message already, don't push-notify. # # TODO: It feels like this is already handled when things are # put in the queue; maybe we should centralize this logic with # the `zerver/tornado/event_queue.py` logic? if user_message.flags.read: return # Otherwise, we mark the message as having an active mobile # push notification, so that we can send revocation messages # later. user_message.flags.active_mobile_push_notification = True user_message.save(update_fields=["flags"]) else: # Users should only be getting push notifications into this # queue for messages they haven't received if they're # long-term idle; anything else is likely a bug. if not user_profile.long_term_idle: logger.error("Could not find UserMessage with message_id %s and user_id %s" % ( missed_message['message_id'], user_profile_id)) return message.trigger = missed_message['trigger'] apns_payload = get_apns_payload(user_profile, message) gcm_payload = get_gcm_payload(user_profile, message) gcm_options = {'priority': 'high'} # type: Dict[str, Any] logger.info("Sending push notifications to mobile clients for user %s" % (user_profile_id,)) if uses_notification_bouncer(): try: send_notifications_to_bouncer(user_profile_id, apns_payload, gcm_payload, gcm_options) except requests.ConnectionError: def failure_processor(event: Dict[str, Any]) -> None: logger.warning( "Maximum retries exceeded for trigger:%s event:push_notification" % ( event['user_profile_id'])) retry_event('missedmessage_mobile_notifications', missed_message, failure_processor) return android_devices = list(PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)) apple_devices = list(PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.APNS)) if apple_devices: send_apple_push_notification(user_profile.id, apple_devices, apns_payload) if android_devices: send_android_push_notification(android_devices, gcm_payload, gcm_options)
def update_message_backend(request, user_profile, message_id=REQ(converter=to_non_negative_int), subject=REQ(default=None), propagate_mode=REQ(default="change_one"), content=REQ(default=None)): # type: (HttpRequest, UserProfile, int, Optional[Text], Optional[str], Optional[Text]) -> HttpResponse if not user_profile.realm.allow_message_editing: return json_error(_("Your organization has turned off message editing.")) message, ignored_user_message = access_message(user_profile, message_id) # You only have permission to edit a message if: # 1. You sent it, OR: # 2. This is a topic-only edit for a (no topic) message, OR: # 3. This is a topic-only edit and you are an admin. if message.sender == user_profile: pass elif (content is None) and ((message.topic_name() == "(no topic)") or user_profile.is_realm_admin): pass else: raise JsonableError(_("You don't have permission to edit this message")) # If there is a change to the content, check that it hasn't been too long # Allow an extra 20 seconds since we potentially allow editing 15 seconds # past the limit, and in case there are network issues, etc. The 15 comes # from (min_seconds_to_edit + seconds_left_buffer) in message_edit.js; if # you change this value also change those two parameters in message_edit.js. edit_limit_buffer = 20 if content is not None and user_profile.realm.message_content_edit_limit_seconds > 0: deadline_seconds = user_profile.realm.message_content_edit_limit_seconds + edit_limit_buffer if (timezone.now() - message.pub_date) > datetime.timedelta(seconds=deadline_seconds): raise JsonableError(_("The time limit for editing this message has past")) if subject is None and content is None: return json_error(_("Nothing to change")) if subject is not None: subject = subject.strip() if subject == "": raise JsonableError(_("Topic can't be empty")) rendered_content = None links_for_embed = set() # type: Set[Text] if content is not None: content = content.strip() if content == "": content = "(deleted)" content = truncate_body(content) # We exclude UserMessage.flags.historical rows since those # users did not receive the message originally, and thus # probably are not relevant for reprocessed alert_words, # mentions and similar rendering features. This may be a # decision we change in the future. ums = UserMessage.objects.filter( message=message.id, flags=~UserMessage.flags.historical) message_users = UserProfile.objects.select_related().filter( id__in={um.user_profile_id for um in ums}) # We render the message using the current user's realm; since # the cross-realm bots never edit messages, this should be # always correct. # Note: If rendering fails, the called code will raise a JsonableError. rendered_content = render_incoming_message(message, content, message_users, user_profile.realm) links_for_embed |= message.links_for_preview number_changed = do_update_message(user_profile, message, subject, propagate_mode, content, rendered_content) # Include the number of messages changed in the logs request._log_data['extra'] = "[%s]" % (number_changed,) if links_for_embed and getattr(settings, 'INLINE_URL_EMBED_PREVIEW', None): event_data = { 'message_id': message.id, 'message_content': message.content, # The choice of `user_profile.realm_id` rather than # `sender.realm_id` must match the decision made in the # `render_incoming_message` call earlier in this function. 'message_realm_id': user_profile.realm_id, 'urls': links_for_embed} queue_json_publish('embed_links', event_data, lambda x: None) return json_success()
def add_reaction( request: HttpRequest, user_profile: UserProfile, message_id: int, emoji_name: str = REQ(), emoji_code: Optional[str] = REQ(default=None), reaction_type: Optional[str] = REQ(default=None) ) -> HttpResponse: message, user_message = access_message(user_profile, message_id) if emoji_code is None: # The emoji_code argument is only required for rare corner # cases discussed in the long block comment below. For simple # API clients, we allow specifying just the name, and just # look up the code using the current name->code mapping. emoji_code = emoji_name_to_emoji_code(message.sender.realm, emoji_name)[0] if reaction_type is None: reaction_type = emoji_name_to_emoji_code(message.sender.realm, emoji_name)[1] if Reaction.objects.filter(user_profile=user_profile, message=message, emoji_code=emoji_code, reaction_type=reaction_type).exists(): raise JsonableError(_("Reaction already exists.")) query = Reaction.objects.filter(message=message, emoji_code=emoji_code, reaction_type=reaction_type) if query.exists(): # If another user has already reacted to this message with # same emoji code, we treat the new reaction as a vote for the # existing reaction. So the emoji name used by that earlier # reaction takes precedence over whatever was passed in this # request. This is necessary to avoid a message having 2 # "different" emoji reactions with the same emoji code (and # thus same image) on the same message, which looks ugly. # # In this "voting for an existing reaction" case, we shouldn't # check whether the emoji code and emoji name match, since # it's possible that the (emoji_type, emoji_name, emoji_code) # triple for this existing rection xmay not pass validation # now (e.g. because it is for a realm emoji that has been # since deactivated). We still want to allow users to add a # vote any old reaction they see in the UI even if that is a # deactivated custom emoji, so we just use the emoji name from # the existing reaction with no further validation. emoji_name = query.first().emoji_name else: # Otherwise, use the name provided in this request, but verify # it is valid in the user's realm (e.g. not a deactivated # realm emoji). check_emoji_request(message.sender.realm, emoji_name, emoji_code, reaction_type) if user_message is None: create_historical_message(user_profile, message) do_add_reaction(user_profile, message, emoji_name, emoji_code, reaction_type) return json_success()
def update_message_backend( request: HttpRequest, user_profile: UserMessage, message_id: int = REQ(converter=to_non_negative_int, path_only=True), stream_id: Optional[int] = REQ(converter=to_non_negative_int, default=None), topic_name: Optional[str] = REQ_topic(), propagate_mode: Optional[str] = REQ( default="change_one", str_validator=check_string_in(PROPAGATE_MODE_VALUES)), send_notification_to_old_thread: bool = REQ(default=True, validator=check_bool), send_notification_to_new_thread: bool = REQ(default=True, validator=check_bool), content: Optional[str] = REQ(default=None) ) -> HttpResponse: if not user_profile.realm.allow_message_editing: return json_error( _("Your organization has turned off message editing")) if propagate_mode != "change_one" and topic_name is None and stream_id is None: return json_error(_("Invalid propagate_mode without topic edit")) message, ignored_user_message = access_message(user_profile, message_id) is_no_topic_msg = (message.topic_name() == "(no topic)") # You only have permission to edit a message if: # you change this value also change those two parameters in message_edit.js. # 1. You sent it, OR: # 2. This is a topic-only edit for a (no topic) message, OR: # 3. This is a topic-only edit and you are an admin, OR: # 4. This is a topic-only edit and your realm allows users to edit topics. if message.sender == user_profile: pass elif (content is None) and (is_no_topic_msg or user_profile.is_realm_admin or user_profile.realm.allow_community_topic_editing): pass else: raise JsonableError( _("You don't have permission to edit this message")) # If there is a change to the content, check that it hasn't been too long # Allow an extra 20 seconds since we potentially allow editing 15 seconds # past the limit, and in case there are network issues, etc. The 15 comes # from (min_seconds_to_edit + seconds_left_buffer) in message_edit.js; if # you change this value also change those two parameters in message_edit.js. edit_limit_buffer = 20 if content is not None and user_profile.realm.message_content_edit_limit_seconds > 0: deadline_seconds = user_profile.realm.message_content_edit_limit_seconds + edit_limit_buffer if (timezone_now() - message.date_sent) > datetime.timedelta( seconds=deadline_seconds): raise JsonableError( _("The time limit for editing this message has passed")) # If there is a change to the topic, check that the user is allowed to # edit it and that it has not been too long. If this is not the user who # sent the message, they are not the admin, and the time limit for editing # topics is passed, raise an error. if content is None and message.sender != user_profile and not user_profile.is_realm_admin and \ not is_no_topic_msg: deadline_seconds = Realm.DEFAULT_COMMUNITY_TOPIC_EDITING_LIMIT_SECONDS + edit_limit_buffer if (timezone_now() - message.date_sent) > datetime.timedelta( seconds=deadline_seconds): raise JsonableError( _("The time limit for editing this message has passed")) if topic_name is None and content is None and stream_id is None: return json_error(_("Nothing to change")) if topic_name is not None: topic_name = topic_name.strip() if topic_name == "": raise JsonableError(_("Topic can't be empty")) rendered_content = None links_for_embed: Set[str] = set() prior_mention_user_ids: Set[int] = set() mention_user_ids: Set[int] = set() mention_data: Optional[MentionData] = None if content is not None: content = content.strip() if content == "": content = "(deleted)" content = truncate_body(content) mention_data = MentionData( realm_id=user_profile.realm.id, content=content, ) user_info = get_user_info_for_message_updates(message.id) prior_mention_user_ids = user_info['mention_user_ids'] # We render the message using the current user's realm; since # the cross-realm bots never edit messages, this should be # always correct. # Note: If rendering fails, the called code will raise a JsonableError. rendered_content = render_incoming_message( message, content, user_info['message_user_ids'], user_profile.realm, mention_data=mention_data) links_for_embed |= message.links_for_preview mention_user_ids = message.mentions_user_ids new_stream = None old_stream = None number_changed = 0 if stream_id is not None: if not user_profile.is_realm_admin: raise JsonableError( _("You don't have permission to move this message")) if content is not None: raise JsonableError( _("Cannot change message content while changing stream")) old_stream = get_stream_by_id(message.recipient.type_id) new_stream = get_stream_by_id(stream_id) if not (old_stream.is_public() and new_stream.is_public()): # We'll likely decide to relax this condition in the # future; it just requires more care with details like the # breadcrumb messages. raise JsonableError(_("Streams must be public")) number_changed = do_update_message( user_profile, message, new_stream, topic_name, propagate_mode, send_notification_to_old_thread, send_notification_to_new_thread, content, rendered_content, prior_mention_user_ids, mention_user_ids, mention_data) # Include the number of messages changed in the logs request._log_data['extra'] = f"[{number_changed}]" if links_for_embed: event_data = { 'message_id': message.id, 'message_content': message.content, # The choice of `user_profile.realm_id` rather than # `sender.realm_id` must match the decision made in the # `render_incoming_message` call earlier in this function. 'message_realm_id': user_profile.realm_id, 'urls': links_for_embed } queue_json_publish('embed_links', event_data) return json_success()
def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None: """ missed_message is the event received by the zerver.worker.queue_processors.PushNotificationWorker.consume function. """ user_profile = get_user_profile_by_id(user_profile_id) if not (receives_offline_push_notifications(user_profile) or receives_online_notifications(user_profile)): return user_profile = get_user_profile_by_id(user_profile_id) (message, user_message) = access_message(user_profile, missed_message['message_id']) if user_message is not None: # If ther user has read the message already, don't push-notify. # # TODO: It feels like this is already handled when things are # put in the queue; maybe we should centralize this logic with # the `zerver/tornado/event_queue.py` logic? if user_message.flags.read: return else: # Users should only be getting push notifications into this # queue for messages they haven't received if they're # long-term idle; anything else is likely a bug. if not user_profile.long_term_idle: logging.error("Could not find UserMessage with message_id %s and user_id %s" % ( missed_message['message_id'], user_profile_id)) return message.trigger = missed_message['trigger'] message.stream_name = missed_message.get('stream_name', None) apns_payload = get_apns_payload(message) gcm_payload = get_gcm_payload(user_profile, message) logging.info("Sending push notification to user %s" % (user_profile_id,)) if uses_notification_bouncer(): try: send_notifications_to_bouncer(user_profile_id, apns_payload, gcm_payload) except requests.ConnectionError: def failure_processor(event: Dict[str, Any]) -> None: logging.warning( "Maximum retries exceeded for trigger:%s event:push_notification" % ( event['user_profile_id'])) retry_event('missedmessage_mobile_notifications', missed_message, failure_processor) return android_devices = list(PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)) apple_devices = list(PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.APNS)) if apple_devices: send_apple_push_notification(user_profile.id, apple_devices, apns_payload) if android_devices: send_android_push_notification(android_devices, gcm_payload)
def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None: """ missed_message is the event received by the zerver.worker.queue_processors.PushNotificationWorker.consume function. """ if not push_notifications_enabled(): return user_profile = get_user_profile_by_id(user_profile_id) if user_profile.is_bot: # BUG: Investigate why it's possible to get here. return # nocoverage if not (user_profile.enable_offline_push_notifications or user_profile.enable_online_push_notifications): # BUG: Investigate why it's possible to get here. return # nocoverage try: (message, user_message) = access_message(user_profile, missed_message["message_id"]) except JsonableError: if ArchivedMessage.objects.filter( id=missed_message["message_id"]).exists(): # If the cause is a race with the message being deleted, # that's normal and we have no need to log an error. return logging.info( "Unexpected message access failure handling push notifications: %s %s", user_profile.id, missed_message["message_id"], ) return if user_message is not None: # If the user has read the message already, don't push-notify. if user_message.flags.read or user_message.flags.active_mobile_push_notification: return # Otherwise, we mark the message as having an active mobile # push notification, so that we can send revocation messages # later. user_message.flags.active_mobile_push_notification = True user_message.save(update_fields=["flags"]) else: # Users should only be getting push notifications into this # queue for messages they haven't received if they're # long-term idle; anything else is likely a bug. if not user_profile.long_term_idle: logger.error( "Could not find UserMessage with message_id %s and user_id %s", missed_message["message_id"], user_profile_id, exc_info=True, ) return trigger = missed_message["trigger"] mentioned_user_group_name = None mentioned_user_group_id = missed_message.get("mentioned_user_group_id") if mentioned_user_group_id is not None: user_group = access_user_group_by_id(mentioned_user_group_id, user_profile, for_mention=True) mentioned_user_group_name = user_group.name apns_payload = get_message_payload_apns(user_profile, message, trigger, mentioned_user_group_id, mentioned_user_group_name) gcm_payload, gcm_options = get_message_payload_gcm( user_profile, message, trigger, mentioned_user_group_id, mentioned_user_group_name) logger.info("Sending push notifications to mobile clients for user %s", user_profile_id) if uses_notification_bouncer(): send_notifications_to_bouncer(user_profile_id, apns_payload, gcm_payload, gcm_options) return android_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)) apple_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.APNS)) send_apple_push_notification(user_profile.id, apple_devices, apns_payload) send_android_push_notification(android_devices, gcm_payload, gcm_options)
def do_update_message_flags(user_profile: UserProfile, operation: str, flag: str, messages: List[int]) -> Tuple[int, List[int]]: valid_flags = [ item for item in UserMessage.flags if item not in UserMessage.NON_API_FLAGS ] if flag not in valid_flags: raise JsonableError(_("Invalid flag: '{}'").format(flag)) if flag in UserMessage.NON_EDITABLE_FLAGS: raise JsonableError(_("Flag not editable: '{}'").format(flag)) if operation not in ("add", "remove"): raise JsonableError( _("Invalid message flag operation: '{}'").format(operation)) flagattr = getattr(UserMessage.flags, flag) msgs = UserMessage.objects.filter(user_profile=user_profile, message_id__in=messages) um_message_ids = {um.message_id for um in msgs} historical_message_ids = list(set(messages) - um_message_ids) # Users can mutate flags for messages that don't have a UserMessage yet. # First, validate that the user is even allowed to access these message_ids. for message_id in historical_message_ids: access_message(user_profile, message_id) # And then create historical UserMessage records. See the called function for more context. create_historical_user_messages(user_id=user_profile.id, message_ids=historical_message_ids) with transaction.atomic(): if operation == "add": msgs = (msgs.select_for_update().order_by("message_id").extra( where=[UserMessage.where_flag_is_absent(flagattr)])) updated_message_ids = [um.message_id for um in msgs] msgs.filter(message_id__in=updated_message_ids).update( flags=F("flags").bitor(flagattr)) elif operation == "remove": msgs = (msgs.select_for_update().order_by("message_id").extra( where=[UserMessage.where_flag_is_present(flagattr)])) updated_message_ids = [um.message_id for um in msgs] msgs.filter(message_id__in=updated_message_ids).update( flags=F("flags").bitand(~flagattr)) count = len(updated_message_ids) event = { "type": "update_message_flags", "op": operation, "operation": operation, "flag": flag, "messages": updated_message_ids, "all": False, } if flag == "read" and operation == "remove": # When removing the read flag (i.e. marking messages as # unread), extend the event with an additional object with # details on the messages required to update the client's # `unread_msgs` data structure. raw_unread_data = get_raw_unread_data(user_profile, updated_message_ids) event["message_details"] = format_unread_message_details( user_profile.id, raw_unread_data) send_event(user_profile.realm, event, [user_profile.id]) if flag == "read" and operation == "add": event_time = timezone_now() do_clear_mobile_push_notifications_for_ids([user_profile.id], updated_message_ids) do_increment_logging_stat(user_profile, COUNT_STATS["messages_read::hour"], None, event_time, increment=count) do_increment_logging_stat( user_profile, COUNT_STATS["messages_read_interactions::hour"], None, event_time, increment=min(1, count), ) return count, updated_message_ids
def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None: """ missed_message is the event received by the zerver.worker.queue_processors.PushNotificationWorker.consume function. """ if not push_notifications_enabled(): return user_profile = get_user_profile_by_id(user_profile_id) if not (receives_offline_push_notifications(user_profile) or receives_online_notifications(user_profile)): return user_profile = get_user_profile_by_id(user_profile_id) try: (message, user_message) = access_message(user_profile, missed_message['message_id']) except JsonableError: if ArchivedMessage.objects.filter( id=missed_message['message_id']).exists(): # If the cause is a race with the message being deleted, # that's normal and we have no need to log an error. return logging.error( "Unexpected message access failure handling push notifications: %s %s" % (user_profile.id, missed_message['message_id'])) return if user_message is not None: # If the user has read the message already, don't push-notify. # # TODO: It feels like this is already handled when things are # put in the queue; maybe we should centralize this logic with # the `zerver/tornado/event_queue.py` logic? if user_message.flags.read: return # Otherwise, we mark the message as having an active mobile # push notification, so that we can send revocation messages # later. user_message.flags.active_mobile_push_notification = True user_message.save(update_fields=["flags"]) else: # Users should only be getting push notifications into this # queue for messages they haven't received if they're # long-term idle; anything else is likely a bug. if not user_profile.long_term_idle: logger.error( "Could not find UserMessage with message_id %s and user_id %s" % (missed_message['message_id'], user_profile_id)) return message.trigger = missed_message['trigger'] apns_payload = get_message_payload_apns(user_profile, message) gcm_payload, gcm_options = get_message_payload_gcm(user_profile, message) logger.info("Sending push notifications to mobile clients for user %s" % (user_profile_id, )) if uses_notification_bouncer(): try: send_notifications_to_bouncer(user_profile_id, apns_payload, gcm_payload, gcm_options) except requests.ConnectionError: def failure_processor(event: Dict[str, Any]) -> None: logger.warning( "Maximum retries exceeded for trigger:%s event:push_notification" % (event['user_profile_id'])) retry_event('missedmessage_mobile_notifications', missed_message, failure_processor) return android_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)) apple_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.APNS)) if apple_devices: send_apple_push_notification(user_profile.id, apple_devices, apns_payload) if android_devices: send_android_push_notification(android_devices, gcm_payload, gcm_options)
def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None: """ missed_message is the event received by the zerver.worker.queue_processors.PushNotificationWorker.consume function. """ user_profile = get_user_profile_by_id(user_profile_id) if not (receives_offline_push_notifications(user_profile) or receives_online_notifications(user_profile)): return user_profile = get_user_profile_by_id(user_profile_id) (message, user_message) = access_message(user_profile, missed_message['message_id']) if user_message is not None: # If ther user has read the message already, don't push-notify. # # TODO: It feels like this is already handled when things are # put in the queue; maybe we should centralize this logic with # the `zerver/tornado/event_queue.py` logic? if user_message.flags.read: return else: # Users should only be getting push notifications into this # queue for messages they haven't received if they're # long-term idle; anything else is likely a bug. if not user_profile.long_term_idle: logging.error( "Could not find UserMessage with message_id %s and user_id %s" % (missed_message['message_id'], user_profile_id)) return message.trigger = missed_message['trigger'] message.stream_name = missed_message.get('stream_name', None) apns_payload = get_apns_payload(message) gcm_payload = get_gcm_payload(user_profile, message) logging.info("Sending push notification to user %s" % (user_profile_id, )) if uses_notification_bouncer(): try: send_notifications_to_bouncer(user_profile_id, apns_payload, gcm_payload) except requests.ConnectionError: def failure_processor(event: Dict[str, Any]) -> None: logging.warning( "Maximum retries exceeded for trigger:%s event:push_notification" % (event['user_profile_id'])) retry_event('missedmessage_mobile_notifications', missed_message, failure_processor) return android_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)) apple_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.APNS)) if apple_devices: send_apple_push_notification(user_profile.id, apple_devices, apns_payload) if android_devices: send_android_push_notification(android_devices, gcm_payload)
def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None: """ missed_message is the event received by the zerver.worker.queue_processors.PushNotificationWorker.consume function. """ if not push_notifications_enabled(): return user_profile = get_user_profile_by_id(user_profile_id) if user_profile.is_bot: # nocoverage # We don't expect to reach here for bot users. However, this code exists # to find and throw away any pre-existing events in the queue while # upgrading from versions before our notifiability logic was implemented. # TODO/compatibility: This block can be removed when one can no longer # upgrade from versions <= 4.0 to versions >= 5.0 logger.warning( "Send-push-notification event found for bot user %s. Skipping.", user_profile_id) return if not (user_profile.enable_offline_push_notifications or user_profile.enable_online_push_notifications): # BUG: Investigate why it's possible to get here. return # nocoverage try: (message, user_message) = access_message(user_profile, missed_message["message_id"]) except JsonableError: if ArchivedMessage.objects.filter( id=missed_message["message_id"]).exists(): # If the cause is a race with the message being deleted, # that's normal and we have no need to log an error. return logging.info( "Unexpected message access failure handling push notifications: %s %s", user_profile.id, missed_message["message_id"], ) return if user_message is not None: # If the user has read the message already, don't push-notify. if user_message.flags.read or user_message.flags.active_mobile_push_notification: return # Otherwise, we mark the message as having an active mobile # push notification, so that we can send revocation messages # later. user_message.flags.active_mobile_push_notification = True user_message.save(update_fields=["flags"]) else: # Users should only be getting push notifications into this # queue for messages they haven't received if they're # long-term idle; anything else is likely a bug. if not user_profile.long_term_idle: logger.error( "Could not find UserMessage with message_id %s and user_id %s", missed_message["message_id"], user_profile_id, exc_info=True, ) return trigger = missed_message["trigger"] mentioned_user_group_name = None # mentioned_user_group_id will be None if the user is personally mentioned # regardless whether they are a member of the mentioned user group in the # message or not. mentioned_user_group_id = missed_message.get("mentioned_user_group_id") if mentioned_user_group_id is not None: user_group = UserGroup.objects.get(id=mentioned_user_group_id, realm=user_profile.realm) mentioned_user_group_name = user_group.name # Soft reactivate if pushing to a long_term_idle user that is personally mentioned soft_reactivate_if_personal_notification(user_profile, {trigger}, mentioned_user_group_name) apns_payload = get_message_payload_apns(user_profile, message, trigger, mentioned_user_group_id, mentioned_user_group_name) gcm_payload, gcm_options = get_message_payload_gcm( user_profile, message, trigger, mentioned_user_group_id, mentioned_user_group_name) logger.info("Sending push notifications to mobile clients for user %s", user_profile_id) if uses_notification_bouncer(): total_android_devices, total_apple_devices = send_notifications_to_bouncer( user_profile_id, apns_payload, gcm_payload, gcm_options) logger.info( "Sent mobile push notifications for user %s through bouncer: %s via FCM devices, %s via APNs devices", user_profile_id, total_android_devices, total_apple_devices, ) return android_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)) apple_devices = list( PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.APNS)) logger.info( "Sending mobile push notifications for local user %s: %s via FCM devices, %s via APNs devices", user_profile_id, len(android_devices), len(apple_devices), ) user_identity = UserPushIndentityCompat(user_id=user_profile.id) send_apple_push_notification(user_identity, apple_devices, apns_payload) send_android_push_notification(user_identity, android_devices, gcm_payload, gcm_options)