diff --git a/README.md b/README.md index 8e07c8c87474737bb90d3bb789ad34963a836807..279241fc8a91109b5eef8f13a3d77b6c1f12cf82 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,6 @@ # Mattermost Coffee Bot The purpose of this small bot is to act as a matchmaker for users that shares channel with the bot. -## Run -Currently the bot only supports one entrypoint when it will run in every channel it knows and make pairs of 2 for every channel. Also, the messages are currently hard-coded in Hungarian, and custom-tailored to KSZK's Mattermost Installation. - -To run this entrypoint -``` -. ./virtualenv/bin/activate - MATTERMOST_URL=<mattermost-url> MATTERMOST_TOKEN=<mattermost-token> ./coffee_bot/run_for_all_channels.py -``` - ## Development To set up a local development environment, run: ``` diff --git a/coffee_bot/coffee_bot.py b/coffee_bot/coffee_bot.py deleted file mode 100644 index 62827422878ef17427d848ac83131334a0cb03a5..0000000000000000000000000000000000000000 --- a/coffee_bot/coffee_bot.py +++ /dev/null @@ -1,119 +0,0 @@ -import copy -import random -from typing import Generator, Iterable -import mattermost - -#pylint: disable=invalid-name - - -def random_nonrepeating_tuples(population: Iterable, n: int) -> Generator[tuple, None, None]: - """ - Generates a random sequence of n-tuples, using every element in population only once - """ - - population_copy = copy.copy(population) - random.shuffle(population_copy) - - buffer = list() - for item in population_copy: - buffer.append(item) - if len(buffer) == n: - yield tuple(buffer) - buffer = [] - - # Flushing the remaining items in case the population count does not divideable by n - if len(buffer) > 0: - yield tuple(buffer) - - -def matching(client: mattermost.MMApi) -> None: - """ - Run through every channel associated with the client and pairs channel members into group chats. - """ - teams = get_my_teams(client) - - for team in teams: - team_channels = client.get_channel_memberships_for_user( - 'me', team['id']) - for channel in team_channels: - channel_id = channel['channel_id'] - members = members_in_channel_besides_me(client, channel_id) - - if len(members) <= 4: - # Too few members in this channel to make meaningful groups - continue - - make_match_in_channel(client, channel_id, members) - - -def handle_no_pair(client: mattermost.MMApi, channel_id: str, group: Iterable[dict]) -> None: - """ - Handle if someone should be left out because they can not paired up - - It sends a message to the channel it runs for so others know if someone had to be left out. - """ - - user = get_user_data(client, group[0]["user_id"]) - client.create_post( - channel_id=channel_id, - message=f"Ebben a körben {user['last_name']} {user['first_name']} nem került sorra sajnos.", - ) - - -def create_group(client: mattermost.MMApi, group: Iterable[dict]) -> None: - """ - Creating a group-message for the group - - Creates a new group-message between the group-members (and the bot itself), - and sends a short welcome message. - """ - - ids = [m['user_id'] for m in group] - groupchat = client.create_group_channel_with(other_user_ids_list=ids) - client.create_post( - channel_id=groupchat['id'], - message="Most ti kerültetek sorra. Jó beszélgetést és kávézást! :coffee_parrot:", - ) - - -def make_match_in_channel(client: mattermost.MMApi, channel_id: str, members: Iterable[dict], - groupsize: int=2) -> None: - """ - Making matches in a channel - """ - - for group in random_nonrepeating_tuples(members, groupsize): - if len(group) == 1: - handle_no_pair(client, channel_id, group) - else: - create_group(client, group) - - -def get_my_teams(client): - """ - Get a list of the teams the bot associated with the client has access to. - - Note: This is function is *not* available in the imported mattermost API binding lib. - """ - # pylint: disable=protected-access - return client._get('/v4/users/me/teams') - - -def members_in_channel_besides_me(client, channel_id): - """ - Gets channel members from a channel and filters out the bot user from the list - - Note: This is function is *not* available in the imported mattermost API binding lib. - """ - # pylint: disable=protected-access - return [m for m in client.get_channel_members(channel_id) if m['user_id'] != client._my_user_id] - - -def get_user_data(client, user_id): - """ - Gets user data for the given user id using the given client. - - Note: This is function is *not* properly implemented by the imported mattermost API binding lib. - """ - # pylint: disable=protected-access - return client._get(f'/v4/users/{user_id}') diff --git a/coffee_bot/match_making.py b/coffee_bot/match_making.py new file mode 100644 index 0000000000000000000000000000000000000000..3d71d9cf42f689cefb16c305b29a8cfbf001eadf --- /dev/null +++ b/coffee_bot/match_making.py @@ -0,0 +1,90 @@ +import copy +import random +from typing import Generator, Iterable, Tuple, List +import mattermost + +#pylint: disable=invalid-name + + +def random_nonrepeating_tuples(population: Iterable, n: int) -> Generator[tuple, None, None]: + """ + Generates a random sequence of n-tuples, using every element in population only once + """ + + population_copy = copy.copy(population) + random.shuffle(population_copy) + + buffer = list() + for item in population_copy: + buffer.append(item) + if len(buffer) == n: + yield tuple(buffer) + buffer = [] + + # Flushing the remaining items in case the population count does not divideable by n + if len(buffer) > 0: + yield tuple(buffer) + + +# pylint: disable=too-many-arguments +def matching_in_channel(client: mattermost.MMApi, channel_id: str, + group_hail=None, left_out_message=None, too_few_message=None, + minimum_members=4, group_size=2) -> None: + """ + Get members of a channel, make matches between these members and handle communication regarding these actions + """ + + members = members_in_channel_besides_me(client, channel_id) + + if len(members) < minimum_members: + if too_few_message: + client.create_post(channel_id=channel_id, message=too_few_message) + return + + groups_created, left_out = make_matches_for_members(client, members, group_size) + + if group_hail: + for group_id in groups_created: + client.create_post(channel_id=group_id, message=group_hail) + + if left_out_message and left_out is not None: + user_data = client.get_user(left_out) + client.create_post(channel_id=channel_id, message=left_out_message.format(user=user_data)) + + +def create_group(client: mattermost.MMApi, ids: Iterable[str]) -> None: + """ + Creating a group-message for the group + """ + + groupchat = client.create_group_channel_with(other_user_ids_list=ids) + + return groupchat['id'] + + +def make_matches_for_members(client: mattermost.MMApi, members: Iterable[dict], + group_size: int = 2) -> Tuple[List[str], str]: + """ + Making matches in a channel + """ + + groups_created = [] + left_out = None + + for group in random_nonrepeating_tuples(members, group_size): + if len(group) == 1: + left_out = group[0]['user_id'] + else: + groups_created.append(create_group(client, [u['user_id'] for u in group])) + + return groups_created, left_out + + +def members_in_channel_besides_me(client, channel_id): + """ + Gets channel members from a channel and filters out the bot user from the list + + Note: This is function is *not* available in the imported mattermost API binding lib. + """ + # pylint: disable=protected-access + return [m for m in client.get_channel_members(channel_id) if m['user_id'] != client._my_user_id] diff --git a/coffee_bot/run_for_all_channels.py b/coffee_bot/run_for_all_channels.py deleted file mode 100755 index 2091447026f4c65de80ce5e86117b6d5b4ba651c..0000000000000000000000000000000000000000 --- a/coffee_bot/run_for_all_channels.py +++ /dev/null @@ -1,11 +0,0 @@ -import mattermost - -from .config import MATTERMOST_TOKEN, MATTERMOST_URL -from .coffee_bot import matching - - -if __name__ == "__main__": - client = mattermost.MMApi(MATTERMOST_URL) - client.login(bearer=MATTERMOST_TOKEN) - - matching(client) diff --git a/tests/test_matchmaking.py b/tests/test_matchmaking.py index 511ab33557f8ea10de893c90244259e5cd82af59..3300d7a6e8f4c9999af61e30a992fc608839e2c4 100644 --- a/tests/test_matchmaking.py +++ b/tests/test_matchmaking.py @@ -4,10 +4,10 @@ import copy import mock import mattermost -from coffee_bot import coffee_bot +from coffee_bot import match_making -class MatchmakingCase(unittest.TestCase): +class MatchMakingCaseBase(unittest.TestCase): def setUp(self): self.my_user_id = "my-bots-user-id" self.client = mock.MagicMock(mattermost.MMApi) @@ -19,77 +19,6 @@ class MatchmakingCase(unittest.TestCase): self.my_channel_membership = self._create_membership_object( self.my_user_id) - def test_exclude_myself(self): - members = self._generate_n_members(self.minimum_membership_requirement) - self._set_channel_members(members, add_myself=True) - - self.assertIn(self.my_channel_membership, self.members) - - members_except_me = coffee_bot.members_in_channel_besides_me( - self.client, "channel-id") - - self.assertNotIn( - self.my_channel_membership, members_except_me) - - self.assertListEqual(members_except_me, members) - - @mock.patch('coffee_bot.coffee_bot.handle_no_pair') - @mock.patch('coffee_bot.coffee_bot.create_group') - def test_even_members(self, create_group, handle_no_pair): - # pylint: disable=invalid-name - n = (self.minimum_membership_requirement + 1) * 2 - members = self._generate_n_members(n) - - coffee_bot.make_match_in_channel(self.client, "channel-id", members) - - self.assertEqual(create_group.call_count, - self.minimum_membership_requirement + 1) - self.assertEqual(handle_no_pair.call_count, 0) - - @mock.patch('coffee_bot.coffee_bot.handle_no_pair') - @mock.patch('coffee_bot.coffee_bot.create_group') - def test_odd_members(self, create_group, handle_no_pair): - # pylint: disable=invalid-name - n = (self.minimum_membership_requirement * 2) + 1 - members = self._generate_n_members(n) - - coffee_bot.make_match_in_channel(self.client, "channel-id", members) - - self.assertEqual(create_group.call_count, - self.minimum_membership_requirement) - self.assertEqual(handle_no_pair.call_count, 1) - - def test_group_creation(self): - members = self._generate_n_members(2) - self.client.create_group_channel_with.return_value = { - "id": "group-chat-id"} - - coffee_bot.create_group(self.client, members) - - self.client.create_group_channel_with.assert_called_with( - other_user_ids_list=['user-id-0', 'user-id-1'] - ) - self.client.create_post.assert_called_with( - channel_id="group-chat-id", - message="Most ti kerültetek sorra. Jó beszélgetést és kávézást! :coffee_parrot:", - ) - - def test_handling_no_pairs(self): - user_id = "user-id-1" - member = self._create_membership_object(user_id) - user_data = {'id': user_id, 'username': "UserId1", - "first_name": "User", "last_name": "Generated"} - - with mock.patch('coffee_bot.coffee_bot.get_user_data', return_value=user_data) as get_user_data: - coffee_bot.handle_no_pair(self.client, "channel-id", (member,)) - - get_user_data.assert_called_with(self.client, user_id) - - self.client.create_post.assert_called_with( - channel_id="channel-id", - message="Ebben a körben Generated User nem került sorra sajnos.", - ) - def _set_channel_members(self, members, add_myself=True): self.members = copy.deepcopy(members) @@ -119,10 +48,157 @@ class MatchmakingCase(unittest.TestCase): "explicit_roles": "", } - def _generate_n_members(self, n, **kwargs): # pylint: disable=invalid-name + @classmethod + def _generate_n_members(cls, n, **kwargs): # pylint: disable=invalid-name members = [] for i in range(0, n): - members.append(self._create_membership_object( + members.append(cls._create_membership_object( f"user-id-{i}", **kwargs)) return members + + +class MatchMakingCase(MatchMakingCaseBase): + def test_exclude_myself(self): + members = self._generate_n_members(self.minimum_membership_requirement) + self._set_channel_members(members, add_myself=True) + + self.assertIn(self.my_channel_membership, self.members) + + members_except_me = match_making.members_in_channel_besides_me( + self.client, "channel-id") + + self.assertNotIn( + self.my_channel_membership, members_except_me) + + self.assertListEqual(members_except_me, members) + + def test_group_creation(self): + self.client.create_group_channel_with.return_value = { + "id": "group-chat-id"} + + group_id = match_making.create_group(self.client, ["user-id-0", "user-id-1"]) + + self.assertEqual(group_id, "group-chat-id") + self.client.create_group_channel_with.assert_called_with( + other_user_ids_list=['user-id-0', 'user-id-1'] + ) + + @mock.patch('coffee_bot.match_making.create_group') + @mock.patch('coffee_bot.match_making.random.shuffle') + def test_making_matches_for_members_even(self, _shuffle, create_group): + members = self._generate_n_members(4) + create_group.side_effect = lambda c, ids: ";".join(ids) + + groups_created, left_out = match_making.make_matches_for_members( + client=self.client, + members=members, + group_size=2, + ) + + self.assertIsNone(left_out, "No one should be left out in this setup") + self.assertListEqual(["user-id-0;user-id-1", "user-id-2;user-id-3"], groups_created) + + @mock.patch('coffee_bot.match_making.create_group') + @mock.patch('coffee_bot.match_making.random.shuffle') + def test_making_matches_for_members_odd(self, _shuffle, create_group): + members = self._generate_n_members(5) + create_group.side_effect = lambda c, ids: ";".join(ids) + + groups_created, left_out = match_making.make_matches_for_members( + client=self.client, + members=members, + group_size=2, + ) + + self.assertEqual(left_out, "user-id-4") + self.assertListEqual(["user-id-0;user-id-1", "user-id-2;user-id-3"], groups_created) + + +@mock.patch('coffee_bot.match_making.make_matches_for_members') +@mock.patch('coffee_bot.match_making.members_in_channel_besides_me') +class MatchingInChannelCase(MatchMakingCaseBase): + def test_minimum_members_with_message(self, members_besides_me, make_matches_for_members): + members_besides_me.return_value = self._generate_n_members(2) + match_making.matching_in_channel( + self.client, + channel_id="channel-id", + too_few_message="Too few", + minimum_members=3, + ) + + self.client.create_post.assert_called_with(channel_id="channel-id", message="Too few") + make_matches_for_members.assert_not_called() + + def test_minimum_members_without_message(self, members_besides_me, make_matches_for_members): + members_besides_me.return_value = self._generate_n_members(2) + match_making.matching_in_channel( + self.client, + channel_id="channel-id", + too_few_message=None, + minimum_members=3, + ) + + self.client.create_post.assert_not_called() + make_matches_for_members.assert_not_called() + + def test_group_hail_with_message(self, members_besides_me, make_matches_for_members): + make_matches_for_members.return_value = (["group-id-1", "group-id-2"], None) + + members_besides_me.return_value = self._generate_n_members(2) + match_making.matching_in_channel( + self.client, + channel_id="channel-id", + group_hail="Hail Group!", + minimum_members=0, + ) + + self.client.create_post.assert_has_calls([ + mock.call(channel_id="group-id-1", message="Hail Group!"), + mock.call(channel_id="group-id-2", message="Hail Group!"), + ]) + + def test_group_hail_without_message(self, members_besides_me, make_matches_for_members): + make_matches_for_members.return_value = (["group-id-1", "group-id-2"], None) + + members_besides_me.return_value = self._generate_n_members(2) + match_making.matching_in_channel( + self.client, + channel_id="channel-id", + group_hail=None, + minimum_members=0, + ) + + self.client.create_post.assert_not_called() + + def test_left_out_with_message(self, members_besides_me, make_matches_for_members): + make_matches_for_members.return_value = ([], "user-id-0") + self.client.get_user.return_value = {'id': "user-id-0", 'username': "UserId0", + "first_name": "Firstname", "last_name": "LASTNAME"} + + members_besides_me.return_value = self._generate_n_members(1) + match_making.matching_in_channel( + self.client, + channel_id="channel-id", + left_out_message="Couldn't match {user[first_name]} {user[last_name]} this time", + minimum_members=0, + ) + + self.client.create_post.assert_called_with( + channel_id="channel-id", + message="Couldn't match Firstname LASTNAME this time", + ) + + def test_left_out_without_message(self, members_besides_me, make_matches_for_members): + make_matches_for_members.return_value = ([], "user-id-0") + + members_besides_me.return_value = self._generate_n_members(2) + match_making.matching_in_channel( + self.client, + channel_id="channel-id", + left_out_message=None, + minimum_members=0, + ) + + self.client.get_user.assert_not_called() + self.client.create_post.assert_not_called() diff --git a/tests/test_random_tuple.py b/tests/test_random_tuple.py index f6c99690d14bf7bcfb0fb249e7916027a2c03683..aa61a35e86f0b3d2e1dd1b2e2e804d28ff77f98b 100644 --- a/tests/test_random_tuple.py +++ b/tests/test_random_tuple.py @@ -1,6 +1,6 @@ # pylint: disable=missing-class-docstring, missing-function-docstring import unittest -from coffee_bot import coffee_bot +from coffee_bot import match_making class RandomNonrepeatingTuplesCase(unittest.TestCase): @@ -8,7 +8,7 @@ class RandomNonrepeatingTuplesCase(unittest.TestCase): population = ["alma", "korte", "cseresznye", "meggy"] # pylint: disable=unnecessary-comprehension - random_pairs = [p for p in coffee_bot.random_nonrepeating_tuples(population, 2)] + random_pairs = [p for p in match_making.random_nonrepeating_tuples(population, 2)] self.assertEqual(len(random_pairs), len(population)/2) @@ -21,7 +21,7 @@ class RandomNonrepeatingTuplesCase(unittest.TestCase): population = ["alma", "korte", "cseresznye"] # pylint: disable=unnecessary-comprehension - random_pairs = [p for p in coffee_bot.random_nonrepeating_tuples(population, 2)] + random_pairs = [p for p in match_making.random_nonrepeating_tuples(population, 2)] self.assertEqual(len(random_pairs), (len(population)+1)/2) @@ -48,6 +48,6 @@ class RandomNonrepeatingTuplesCase(unittest.TestCase): population = [] # pylint: disable=unnecessary-comprehension - random_pairs = [p for p in coffee_bot.random_nonrepeating_tuples(population, 2)] + random_pairs = [p for p in match_making.random_nonrepeating_tuples(population, 2)] self.assertEqual(len(random_pairs), 0)