Repository: zulip/zulip

Issue:
Notify users when they are added to a group
Just as we notify users when someone else adds them to a stream, we should also notify users when someone else adds them to a group, by sending a Notification Bot message.

In parallel with the stream notification message ("{user_full_name} subscribed you to the stream {stream_name}."), the message text can be:

"{user_full_name} subscribed you to the group @{group_name}."

As the groups project (https://github.com/zulip/zulip/issues/19525) moves along, we may want to add more information to the notification, e.g. groups of which the new group is a subgroup, or something about associated permissions.

[CZO discussion](https://chat.zulip.org/#narrow/stream/137-feedback/topic/inform.20user.20about.20changes.20to.20their.20profile/near/1468096)


Discussion:
Hello @zulip/server-bots members, this issue was labeled with the "area: bots" label, so you may want to check it out!

<!-- areaLabelAddition -->

@zulipbot claim
Welcome to Zulip, @gopipate! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!

Welcome to Zulip, @TanatornG! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!

@zulipbot claim
@ntessi This issue cannot be claimed, as someone else is already working on it. Please see our [contributor guide](https://zulip.readthedocs.io/en/latest/overview/contributing.html#your-first-codebase-contribution) for advice on finding an issue to work on. Thanks!

@gopipate, @TanatornG You have been unassigned from this issue because you have not made any updates for over 14 days. Please feel free to reclaim the issue if you decide to pick up again. Thanks!

PR has been made at [https://github.com/zulip/zulip/pull/23784](https://github.com/zulip/zulip/pull/23784) 
@zulipbot claim
@msridhar1 This issue cannot be claimed, as someone else is already working on it. Please see our [contributor guide](https://zulip.readthedocs.io/en/latest/overview/contributing.html#your-first-codebase-contribution) for advice on finding an issue to work on. Thanks!

@zulipbot claim

Welcome to Zulip, @vishisht08! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!

@vishisht08 We noticed that you have not made any updates to this issue or linked PRs for 10 days. Please comment here if you are still actively working on it. Otherwise, we'd appreciate a quick `@zulipbot abandon` comment so that someone else can claim this issue and continue from where you left off.

If we don't hear back, you will be automatically unassigned in 4 days. Thanks!

<!-- inactiveWarning -->

@zulipbot claim
Hello @Ujjawal3, it looks like you've currently claimed 1 issue in this repository. We encourage new contributors to focus their efforts on at most 1 issue at a time, so please complete your work on your other claimed issues before trying to claim this issue again.

We look forward to your valuable contributions!


Resolving patch:
diff --git a/zerver/openapi/curl_param_value_generators.py b/zerver/openapi/curl_param_value_generators.py
--- a/zerver/openapi/curl_param_value_generators.py
+++ b/zerver/openapi/curl_param_value_generators.py
@@ -126,7 +126,7 @@ def add_emoji_to_message() -> Dict[str, object]:
 
     # The message ID here is hardcoded based on the corresponding value
     # for the example message IDs we use in zulip.yaml.
-    message_id = 46
+    message_id = 47
     emoji_name = "octopus"
     emoji_code = "1f419"
     reaction_type = "unicode_emoji"
diff --git a/zerver/views/user_groups.py b/zerver/views/user_groups.py
--- a/zerver/views/user_groups.py
+++ b/zerver/views/user_groups.py
@@ -1,8 +1,11 @@
-from typing import Optional, Sequence
+from typing import List, Optional, Sequence
 
+from django.conf import settings
 from django.http import HttpRequest, HttpResponse
 from django.utils.translation import gettext as _
+from django.utils.translation import override as override_language
 
+from zerver.actions.message_send import do_send_messages, internal_prep_private_message
 from zerver.actions.user_groups import (
     add_subgroups_to_user_group,
     bulk_add_members_to_user_group,
@@ -15,6 +18,7 @@
 )
 from zerver.decorator import require_member_or_admin, require_user_group_edit_permission
 from zerver.lib.exceptions import JsonableError
+from zerver.lib.mention import MentionBackend, silent_mention_syntax_for_user
 from zerver.lib.request import REQ, has_request_variables
 from zerver.lib.response import json_success
 from zerver.lib.user_groups import (
@@ -29,7 +33,7 @@
 )
 from zerver.lib.users import access_user_by_id, user_ids_to_users
 from zerver.lib.validator import check_bool, check_int, check_list
-from zerver.models import UserProfile
+from zerver.models import UserGroup, UserProfile, get_system_bot
 from zerver.views.streams import compose_views
 
 
@@ -115,6 +119,55 @@ def update_user_group_backend(
     return json_success(request, data)
 
 
+def notify_for_user_group_subscription_changes(
+    acting_user: UserProfile,
+    recipient_users: List[UserProfile],
+    user_group: UserGroup,
+    *,
+    send_subscription_message: bool = False,
+    send_unsubscription_message: bool = False,
+) -> None:
+    realm = acting_user.realm
+    mention_backend = MentionBackend(realm.id)
+
+    notifications = []
+    notification_bot = get_system_bot(settings.NOTIFICATION_BOT, realm.id)
+    for recipient_user in recipient_users:
+        if recipient_user.id == acting_user.id:
+            # Don't send notification message if you subscribed/unsubscribed yourself.
+            continue
+        if recipient_user.is_bot:
+            # Don't send notification message to bots.
+            continue
+        if not recipient_user.is_active:
+            # Don't send notification message to deactivated users.
+            continue
+
+        with override_language(recipient_user.default_language):
+            if send_subscription_message:
+                message = _("{user_full_name} added you to the group {group_name}.").format(
+                    user_full_name=silent_mention_syntax_for_user(acting_user),
+                    group_name=f"@_*{user_group.name}*",
+                )
+            if send_unsubscription_message:
+                message = _("{user_full_name} removed you from the group {group_name}.").format(
+                    user_full_name=silent_mention_syntax_for_user(acting_user),
+                    group_name=f"@_*{user_group.name}*",
+                )
+
+        notifications.append(
+            internal_prep_private_message(
+                sender=notification_bot,
+                recipient_user=recipient_user,
+                content=message,
+                mention_backend=mention_backend,
+            )
+        )
+
+    if len(notifications) > 0:
+        do_send_messages(notifications)
+
+
 def add_members_to_group_backend(
     request: HttpRequest, user_profile: UserProfile, user_group_id: int, members: Sequence[int]
 ) -> HttpResponse:
@@ -122,19 +175,25 @@ def add_members_to_group_backend(
         return json_success(request)
 
     user_group = access_user_group_by_id(user_group_id, user_profile)
-    user_profiles = user_ids_to_users(members, user_profile.realm)
-    existing_member_ids = set(get_direct_memberships_of_users(user_group, user_profiles))
+    member_users = user_ids_to_users(members, user_profile.realm)
+    existing_member_ids = set(get_direct_memberships_of_users(user_group, member_users))
 
-    for user_profile in user_profiles:
-        if user_profile.id in existing_member_ids:
+    for member_user in member_users:
+        if member_user.id in existing_member_ids:
             raise JsonableError(
                 _("User {user_id} is already a member of this group").format(
-                    user_id=user_profile.id,
+                    user_id=member_user.id,
                 )
             )
 
-    user_profile_ids = [user.id for user in user_profiles]
-    bulk_add_members_to_user_group(user_group, user_profile_ids, acting_user=user_profile)
+    member_user_ids = [member_user.id for member_user in member_users]
+    bulk_add_members_to_user_group(user_group, member_user_ids, acting_user=user_profile)
+    notify_for_user_group_subscription_changes(
+        acting_user=user_profile,
+        recipient_users=member_users,
+        user_group=user_group,
+        send_subscription_message=True,
+    )
     return json_success(request)
 
 
@@ -153,6 +212,12 @@ def remove_members_from_group_backend(
 
     user_profile_ids = [user.id for user in user_profiles]
     remove_members_from_user_group(user_group, user_profile_ids, acting_user=user_profile)
+    notify_for_user_group_subscription_changes(
+        acting_user=user_profile,
+        recipient_users=user_profiles,
+        user_group=user_group,
+        send_unsubscription_message=True,
+    )
     return json_success(request)
 
 


Test patch:
diff --git a/zerver/tests/test_user_groups.py b/zerver/tests/test_user_groups.py
--- a/zerver/tests/test_user_groups.py
+++ b/zerver/tests/test_user_groups.py
@@ -7,6 +7,8 @@
 
 from zerver.actions.realm_settings import do_set_realm_property
 from zerver.actions.user_groups import check_add_user_group, promote_new_full_members
+from zerver.actions.users import do_deactivate_user
+from zerver.lib.mention import silent_mention_syntax_for_user
 from zerver.lib.streams import ensure_stream
 from zerver.lib.test_classes import ZulipTestCase
 from zerver.lib.test_helpers import most_recent_usermessage
@@ -361,14 +363,27 @@ def test_update_members_of_user_group(self) -> None:
         self.assertEqual(UserGroupMembership.objects.filter(user_group=user_group).count(), 1)
 
         othello = self.example_user("othello")
-        add = [othello.id]
-        params = {"add": orjson.dumps(add).decode()}
+        # A bot
+        webhook_bot = self.example_user("webhook_bot")
+        # A deactivated user
+        iago = self.example_user("iago")
+        do_deactivate_user(iago, acting_user=None)
+
+        params = {"add": orjson.dumps([othello.id]).decode()}
+        initial_last_message = self.get_last_message()
         result = self.client_post(f"/json/user_groups/{user_group.id}/members", info=params)
         self.assert_json_success(result)
         self.assertEqual(UserGroupMembership.objects.filter(user_group=user_group).count(), 2)
         members = get_direct_memberships_of_users(user_group, [hamlet, othello])
         self.assert_length(members, 2)
 
+        # A notification message is sent for adding to user group.
+        self.assertNotEqual(self.get_last_message(), initial_last_message)
+        expected_notification = (
+            f"{silent_mention_syntax_for_user(hamlet)} added you to the group @_*support*."
+        )
+        self.assertEqual(self.get_last_message().content, expected_notification)
+
         # Test adding a member already there.
         result = self.client_post(f"/json/user_groups/{user_group.id}/members", info=params)
         self.assert_json_error(result, f"User {othello.id} is already a member of this group")
@@ -376,6 +391,24 @@ def test_update_members_of_user_group(self) -> None:
         members = get_direct_memberships_of_users(user_group, [hamlet, othello])
         self.assert_length(members, 2)
 
+        # Test user adding itself,bot and deactivated user to user group.
+        desdemona = self.example_user("desdemona")
+        self.login_user(desdemona)
+
+        params = {"add": orjson.dumps([desdemona.id, iago.id, webhook_bot.id]).decode()}
+        initial_last_message = self.get_last_message()
+        result = self.client_post(f"/json/user_groups/{user_group.id}/members", info=params)
+        self.assert_json_success(result)
+
+        self.assertEqual(UserGroupMembership.objects.filter(user_group=user_group).count(), 5)
+        members = get_direct_memberships_of_users(
+            user_group, [hamlet, othello, desdemona, iago, webhook_bot]
+        )
+        self.assert_length(members, 5)
+
+        # No notification message is sent for adding to user group.
+        self.assertEqual(self.get_last_message(), initial_last_message)
+
         aaron = self.example_user("aaron")
 
         # For normal testing we again log in with hamlet
@@ -383,20 +416,44 @@ def test_update_members_of_user_group(self) -> None:
         self.login_user(hamlet)
         # Test remove members
         params = {"delete": orjson.dumps([othello.id]).decode()}
+        initial_last_message = self.get_last_message()
         result = self.client_post(f"/json/user_groups/{user_group.id}/members", info=params)
         self.assert_json_success(result)
-        self.assertEqual(UserGroupMembership.objects.filter(user_group=user_group).count(), 1)
-        members = get_direct_memberships_of_users(user_group, [hamlet, othello, aaron])
-        self.assert_length(members, 1)
+
+        self.assertEqual(UserGroupMembership.objects.filter(user_group=user_group).count(), 4)
+        members = get_direct_memberships_of_users(
+            user_group, [hamlet, othello, aaron, desdemona, webhook_bot, iago]
+        )
+        self.assert_length(members, 4)
+
+        # A notification message is sent for removing from user group.
+        self.assertNotEqual(self.get_last_message(), initial_last_message)
+        expected_notification = (
+            f"{silent_mention_syntax_for_user(hamlet)} removed you from the group @_*support*."
+        )
+        self.assertEqual(self.get_last_message().content, expected_notification)
 
         # Test remove a member that's already removed
         params = {"delete": orjson.dumps([othello.id]).decode()}
         result = self.client_post(f"/json/user_groups/{user_group.id}/members", info=params)
         self.assert_json_error(result, f"There is no member '{othello.id}' in this user group")
+
+        # Test user remove itself,bot and deactivated user from user group.
+        desdemona = self.example_user("desdemona")
+        self.login_user(desdemona)
+
+        params = {"delete": orjson.dumps([desdemona.id, iago.id, webhook_bot.id]).decode()}
+        initial_last_message = self.get_last_message()
+        result = self.client_post(f"/json/user_groups/{user_group.id}/members", info=params)
+        self.assert_json_success(result)
+
         self.assertEqual(UserGroupMembership.objects.filter(user_group=user_group).count(), 1)
-        members = get_direct_memberships_of_users(user_group, [hamlet, othello, aaron])
+        members = get_direct_memberships_of_users(user_group, [hamlet, othello, desdemona])
         self.assert_length(members, 1)
 
+        # No notification message is sent for removing from user group.
+        self.assertEqual(self.get_last_message(), initial_last_message)
+
         # Test when nothing is provided
         result = self.client_post(f"/json/user_groups/{user_group.id}/members", info={})
         msg = 'Nothing to do. Specify at least one of "add" or "delete".'