import math from dataclasses import dataclass from typing import Any, Collection, Dict, List, Optional, Set from zerver.lib.mention import MentionData from zerver.lib.user_groups import get_user_group_direct_member_ids from zerver.models import NotificationTriggers, UserGroup, UserProfile, UserTopic @dataclass class UserMessageNotificationsData: user_id: int online_push_enabled: bool dm_email_notify: bool dm_push_notify: bool mention_email_notify: bool mention_push_notify: bool topic_wildcard_mention_email_notify: bool topic_wildcard_mention_push_notify: bool stream_wildcard_mention_email_notify: bool stream_wildcard_mention_push_notify: bool stream_push_notify: bool stream_email_notify: bool followed_topic_push_notify: bool followed_topic_email_notify: bool topic_wildcard_mention_in_followed_topic_push_notify: bool topic_wildcard_mention_in_followed_topic_email_notify: bool stream_wildcard_mention_in_followed_topic_push_notify: bool stream_wildcard_mention_in_followed_topic_email_notify: bool sender_is_muted: bool disable_external_notifications: bool def __post_init__(self) -> None: # Check that there's no dubious data. if self.dm_email_notify or self.dm_push_notify: assert not ( self.stream_email_notify or self.stream_push_notify or self.followed_topic_email_notify or self.followed_topic_push_notify ) if ( self.stream_email_notify or self.stream_push_notify or self.followed_topic_email_notify or self.followed_topic_push_notify ): assert not (self.dm_email_notify or self.dm_push_notify) @classmethod def from_user_id_sets( cls, *, user_id: int, flags: Collection[str], private_message: bool, disable_external_notifications: bool, online_push_user_ids: Set[int], dm_mention_push_disabled_user_ids: Set[int], dm_mention_email_disabled_user_ids: Set[int], stream_push_user_ids: Set[int], stream_email_user_ids: Set[int], topic_wildcard_mention_user_ids: Set[int], stream_wildcard_mention_user_ids: Set[int], followed_topic_push_user_ids: Set[int], followed_topic_email_user_ids: Set[int], topic_wildcard_mention_in_followed_topic_user_ids: Set[int], stream_wildcard_mention_in_followed_topic_user_ids: Set[int], muted_sender_user_ids: Set[int], all_bot_user_ids: Set[int], ) -> "UserMessageNotificationsData": if user_id in all_bot_user_ids: # Don't send any notifications to bots return cls( user_id=user_id, dm_email_notify=False, mention_email_notify=False, topic_wildcard_mention_email_notify=False, stream_wildcard_mention_email_notify=False, dm_push_notify=False, mention_push_notify=False, topic_wildcard_mention_push_notify=False, stream_wildcard_mention_push_notify=False, online_push_enabled=False, stream_push_notify=False, stream_email_notify=False, followed_topic_push_notify=False, followed_topic_email_notify=False, topic_wildcard_mention_in_followed_topic_push_notify=False, topic_wildcard_mention_in_followed_topic_email_notify=False, stream_wildcard_mention_in_followed_topic_push_notify=False, stream_wildcard_mention_in_followed_topic_email_notify=False, sender_is_muted=False, disable_external_notifications=False, ) # `stream_wildcard_mention_user_ids`, `topic_wildcard_mention_user_ids`, # `stream_wildcard_mention_in_followed_topic_user_ids` and `topic_wildcard_mention_in_followed_topic_user_ids` # are those user IDs for whom stream or topic wildcard mentions should obey notification # settings for personal mentions. Hence, it isn't an independent notification setting and acts as a wrapper. dm_email_notify = user_id not in dm_mention_email_disabled_user_ids and private_message mention_email_notify = ( user_id not in dm_mention_email_disabled_user_ids and "mentioned" in flags ) topic_wildcard_mention_email_notify = ( user_id in topic_wildcard_mention_user_ids and user_id not in dm_mention_email_disabled_user_ids and "wildcard_mentioned" in flags ) stream_wildcard_mention_email_notify = ( user_id in stream_wildcard_mention_user_ids and user_id not in dm_mention_email_disabled_user_ids and "wildcard_mentioned" in flags ) topic_wildcard_mention_in_followed_topic_email_notify = ( user_id in topic_wildcard_mention_in_followed_topic_user_ids and user_id not in dm_mention_email_disabled_user_ids and "wildcard_mentioned" in flags ) stream_wildcard_mention_in_followed_topic_email_notify = ( user_id in stream_wildcard_mention_in_followed_topic_user_ids and user_id not in dm_mention_email_disabled_user_ids and "wildcard_mentioned" in flags ) dm_push_notify = user_id not in dm_mention_push_disabled_user_ids and private_message mention_push_notify = ( user_id not in dm_mention_push_disabled_user_ids and "mentioned" in flags ) topic_wildcard_mention_push_notify = ( user_id in topic_wildcard_mention_user_ids and user_id not in dm_mention_push_disabled_user_ids and "wildcard_mentioned" in flags ) stream_wildcard_mention_push_notify = ( user_id in stream_wildcard_mention_user_ids and user_id not in dm_mention_push_disabled_user_ids and "wildcard_mentioned" in flags ) topic_wildcard_mention_in_followed_topic_push_notify = ( user_id in topic_wildcard_mention_in_followed_topic_user_ids and user_id not in dm_mention_push_disabled_user_ids and "wildcard_mentioned" in flags ) stream_wildcard_mention_in_followed_topic_push_notify = ( user_id in stream_wildcard_mention_in_followed_topic_user_ids and user_id not in dm_mention_push_disabled_user_ids and "wildcard_mentioned" in flags ) return cls( user_id=user_id, dm_email_notify=dm_email_notify, mention_email_notify=mention_email_notify, topic_wildcard_mention_email_notify=topic_wildcard_mention_email_notify, stream_wildcard_mention_email_notify=stream_wildcard_mention_email_notify, dm_push_notify=dm_push_notify, mention_push_notify=mention_push_notify, topic_wildcard_mention_push_notify=topic_wildcard_mention_push_notify, stream_wildcard_mention_push_notify=stream_wildcard_mention_push_notify, online_push_enabled=user_id in online_push_user_ids, stream_push_notify=user_id in stream_push_user_ids, stream_email_notify=user_id in stream_email_user_ids, followed_topic_push_notify=user_id in followed_topic_push_user_ids, followed_topic_email_notify=user_id in followed_topic_email_user_ids, topic_wildcard_mention_in_followed_topic_push_notify=topic_wildcard_mention_in_followed_topic_push_notify, topic_wildcard_mention_in_followed_topic_email_notify=topic_wildcard_mention_in_followed_topic_email_notify, stream_wildcard_mention_in_followed_topic_push_notify=stream_wildcard_mention_in_followed_topic_push_notify, stream_wildcard_mention_in_followed_topic_email_notify=stream_wildcard_mention_in_followed_topic_email_notify, sender_is_muted=user_id in muted_sender_user_ids, disable_external_notifications=disable_external_notifications, ) # For these functions, acting_user_id is the user sent a message # (or edited a message) triggering the event for which we need to # determine notifiability. def trivially_should_not_notify(self, acting_user_id: int) -> bool: """Common check for reasons not to trigger a notification that arex independent of users' notification settings and thus don't depend on what type of notification (email/push) it is. """ if self.user_id == acting_user_id: return True if self.sender_is_muted: return True if self.disable_external_notifications: return True return False def is_notifiable(self, acting_user_id: int, idle: bool) -> bool: return self.is_email_notifiable(acting_user_id, idle) or self.is_push_notifiable( acting_user_id, idle ) def is_push_notifiable(self, acting_user_id: int, idle: bool) -> bool: return self.get_push_notification_trigger(acting_user_id, idle) is not None def get_push_notification_trigger(self, acting_user_id: int, idle: bool) -> Optional[str]: if not idle and not self.online_push_enabled: return None if self.trivially_should_not_notify(acting_user_id): return None # The order here is important. If, for example, both # `mention_push_notify` and `stream_push_notify` are True, we # want to classify it as a mention, since that's more salient. if self.dm_push_notify: return NotificationTriggers.DIRECT_MESSAGE elif self.mention_push_notify: return NotificationTriggers.MENTION elif self.topic_wildcard_mention_in_followed_topic_push_notify: return NotificationTriggers.TOPIC_WILDCARD_MENTION_IN_FOLLOWED_TOPIC elif self.stream_wildcard_mention_in_followed_topic_push_notify: return NotificationTriggers.STREAM_WILDCARD_MENTION_IN_FOLLOWED_TOPIC elif self.topic_wildcard_mention_push_notify: return NotificationTriggers.TOPIC_WILDCARD_MENTION elif self.stream_wildcard_mention_push_notify: return NotificationTriggers.STREAM_WILDCARD_MENTION elif self.followed_topic_push_notify: return NotificationTriggers.FOLLOWED_TOPIC_PUSH elif self.stream_push_notify: return NotificationTriggers.STREAM_PUSH else: return None def is_email_notifiable(self, acting_user_id: int, idle: bool) -> bool: return self.get_email_notification_trigger(acting_user_id, idle) is not None def get_email_notification_trigger(self, acting_user_id: int, idle: bool) -> Optional[str]: if not idle: return None if self.trivially_should_not_notify(acting_user_id): return None # The order here is important. If, for example, both # `mention_email_notify` and `stream_email_notify` are True, we # want to classify it as a mention, since that's more salient. if self.dm_email_notify: return NotificationTriggers.DIRECT_MESSAGE elif self.mention_email_notify: return NotificationTriggers.MENTION elif self.topic_wildcard_mention_in_followed_topic_email_notify: return NotificationTriggers.TOPIC_WILDCARD_MENTION_IN_FOLLOWED_TOPIC elif self.stream_wildcard_mention_in_followed_topic_email_notify: return NotificationTriggers.STREAM_WILDCARD_MENTION_IN_FOLLOWED_TOPIC elif self.topic_wildcard_mention_email_notify: return NotificationTriggers.TOPIC_WILDCARD_MENTION elif self.stream_wildcard_mention_email_notify: return NotificationTriggers.STREAM_WILDCARD_MENTION elif self.followed_topic_email_notify: return NotificationTriggers.FOLLOWED_TOPIC_EMAIL elif self.stream_email_notify: return NotificationTriggers.STREAM_EMAIL else: return None def user_allows_notifications_in_StreamTopic( stream_is_muted: bool, visibility_policy: int, stream_specific_setting: Optional[bool], global_setting: bool, ) -> bool: """ Captures the hierarchy of notification settings, where visibility policy is considered first, followed by stream-specific settings, and the global-setting in the UserProfile is the fallback. """ if stream_is_muted and visibility_policy != UserTopic.VisibilityPolicy.UNMUTED: return False if visibility_policy == UserTopic.VisibilityPolicy.MUTED: return False if stream_specific_setting is not None: return stream_specific_setting return global_setting def get_user_group_mentions_data( mentioned_user_ids: Set[int], mentioned_user_group_ids: List[int], mention_data: MentionData ) -> Dict[int, int]: # Maps user_id -> mentioned user_group_id mentioned_user_groups_map: Dict[int, int] = dict() # Add members of the mentioned user groups into `mentions_user_ids`. for group_id in mentioned_user_group_ids: member_ids = mention_data.get_group_members(group_id) for member_id in member_ids: if member_id in mentioned_user_ids: # If a user is also mentioned personally, we use that as a trigger # for notifications. continue if member_id in mentioned_user_groups_map: # If multiple user groups are mentioned, we prefer the # user group with the least members for email/mobile # notifications. previous_group_id = mentioned_user_groups_map[member_id] previous_group_member_ids = mention_data.get_group_members(previous_group_id) if len(previous_group_member_ids) > len(member_ids): mentioned_user_groups_map[member_id] = group_id else: mentioned_user_groups_map[member_id] = group_id return mentioned_user_groups_map def get_mentioned_user_group_name( messages: List[Dict[str, Any]], user_profile: UserProfile ) -> Optional[str]: """Returns the user group name to display in the email notification if user group(s) are mentioned. This implements the same algorithm as get_user_group_mentions_data in zerver/lib/notification_data.py, but we're passed a list of messages instead. """ for message in messages: if ( message["mentioned_user_group_id"] is None and message["trigger"] == NotificationTriggers.MENTION ): # The user has also been personally mentioned, so that gets prioritized. return None # These IDs are those of the smallest user groups mentioned in each message. mentioned_user_group_ids = [ message["mentioned_user_group_id"] for message in messages if message["mentioned_user_group_id"] is not None ] # We now want to calculate the name of the smallest user group mentioned among # all these messages. smallest_user_group_size = math.inf smallest_user_group_name = None for user_group_id in mentioned_user_group_ids: current_user_group = UserGroup.objects.get(id=user_group_id, realm=user_profile.realm) current_user_group_size = len(get_user_group_direct_member_ids(current_user_group)) if current_user_group_size < smallest_user_group_size: # If multiple user groups are mentioned, we prefer the # user group with the least members. smallest_user_group_size = current_user_group_size smallest_user_group_name = current_user_group.name return smallest_user_group_name