Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
414 lines
18 KiB
Python
414 lines
18 KiB
Python
import math
|
|
from collections.abc import Collection
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from zerver.lib.mention import MentionData
|
|
from zerver.lib.user_groups import get_user_group_member_ids
|
|
from zerver.models import NamedUserGroup, UserProfile, UserTopic
|
|
from zerver.models.scheduled_jobs import NotificationTriggers
|
|
|
|
|
|
@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],
|
|
push_device_registered_user_ids: set[int] | None,
|
|
) -> "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 "topic_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 "stream_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 "topic_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 "stream_wildcard_mentioned" in flags
|
|
)
|
|
|
|
# TODO/compatibility: `push_device_registered_user_ids` is None when
|
|
# `process_message_event`/`process_message_update_event` handles events
|
|
# prior to the introduction of `push_device_registered_user_ids` field in the events.
|
|
# We hardcode `push_device_registered` to True as the check is meant for newer events.
|
|
#
|
|
# Remove the `if` block when one can no longer directly upgrade from 11.x to main.
|
|
if push_device_registered_user_ids is None:
|
|
push_device_registered = True # nocoverage
|
|
else:
|
|
push_device_registered = user_id in push_device_registered_user_ids
|
|
|
|
dm_push_notify = (
|
|
push_device_registered
|
|
and user_id not in dm_mention_push_disabled_user_ids
|
|
and private_message
|
|
)
|
|
mention_push_notify = (
|
|
push_device_registered
|
|
and user_id not in dm_mention_push_disabled_user_ids
|
|
and "mentioned" in flags
|
|
)
|
|
topic_wildcard_mention_push_notify = (
|
|
push_device_registered
|
|
and user_id in topic_wildcard_mention_user_ids
|
|
and user_id not in dm_mention_push_disabled_user_ids
|
|
and "topic_wildcard_mentioned" in flags
|
|
)
|
|
stream_wildcard_mention_push_notify = (
|
|
push_device_registered
|
|
and user_id in stream_wildcard_mention_user_ids
|
|
and user_id not in dm_mention_push_disabled_user_ids
|
|
and "stream_wildcard_mentioned" in flags
|
|
)
|
|
topic_wildcard_mention_in_followed_topic_push_notify = (
|
|
push_device_registered
|
|
and user_id in topic_wildcard_mention_in_followed_topic_user_ids
|
|
and user_id not in dm_mention_push_disabled_user_ids
|
|
and "topic_wildcard_mentioned" in flags
|
|
)
|
|
stream_wildcard_mention_in_followed_topic_push_notify = (
|
|
push_device_registered
|
|
and user_id in stream_wildcard_mention_in_followed_topic_user_ids
|
|
and user_id not in dm_mention_push_disabled_user_ids
|
|
and "stream_wildcard_mentioned" in flags
|
|
)
|
|
online_push_enabled = push_device_registered and user_id in online_push_user_ids
|
|
stream_push_notify = push_device_registered and user_id in stream_push_user_ids
|
|
followed_topic_push_notify = (
|
|
push_device_registered and user_id in followed_topic_push_user_ids
|
|
)
|
|
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=online_push_enabled,
|
|
stream_push_notify=stream_push_notify,
|
|
stream_email_notify=user_id in stream_email_user_ids,
|
|
followed_topic_push_notify=followed_topic_push_notify,
|
|
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 are
|
|
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) -> str | None:
|
|
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) -> str | None:
|
|
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: bool | None,
|
|
global_setting: bool,
|
|
channel_specific_setting_overrides_mute: 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.
|
|
|
|
When `channel_specific_setting_overrides_mute` is True (currently used for
|
|
`wildcard_mentions_notify` setting), `stream_specific_setting` overrides
|
|
channel mute, but not topic mute.
|
|
"""
|
|
# Muted topics always suppress notifications, regardless of other settings.
|
|
if visibility_policy == UserTopic.VisibilityPolicy.MUTED:
|
|
return False
|
|
|
|
if stream_is_muted and visibility_policy != UserTopic.VisibilityPolicy.UNMUTED:
|
|
if channel_specific_setting_overrides_mute and stream_specific_setting is not None:
|
|
return stream_specific_setting
|
|
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
|
|
|
|
|
|
@dataclass
|
|
class MentionedUserGroup:
|
|
id: int
|
|
name: str
|
|
members_count: int
|
|
|
|
|
|
def get_mentioned_user_group(
|
|
messages: list[dict[str, Any]], user_profile: UserProfile
|
|
) -> MentionedUserGroup | None:
|
|
"""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.get("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.get("mentioned_user_group_id") is not None
|
|
]
|
|
|
|
if len(mentioned_user_group_ids) == 0:
|
|
return None
|
|
|
|
# We now want to calculate the name of the smallest user group mentioned among
|
|
# all these messages.
|
|
# Deduplicate IDs while preserving message order.
|
|
unique_user_group_ids = list(dict.fromkeys(mentioned_user_group_ids))
|
|
user_groups_by_id = {
|
|
user_group.id: user_group
|
|
for user_group in NamedUserGroup.objects.filter(
|
|
id__in=unique_user_group_ids, realm_for_sharding=user_profile.realm
|
|
)
|
|
}
|
|
|
|
smallest_user_group_size = math.inf
|
|
for user_group_id in unique_user_group_ids:
|
|
current_user_group = user_groups_by_id[user_group_id]
|
|
current_mentioned_user_group = MentionedUserGroup(
|
|
id=current_user_group.id,
|
|
name=current_user_group.name,
|
|
members_count=len(get_user_group_member_ids(current_user_group)),
|
|
)
|
|
|
|
if current_mentioned_user_group.members_count < smallest_user_group_size:
|
|
# If multiple user groups are mentioned, we prefer the
|
|
# user group with the least members.
|
|
smallest_user_group_size = current_mentioned_user_group.members_count
|
|
smallest_mentioned_user_group = current_mentioned_user_group
|
|
|
|
return smallest_mentioned_user_group
|