diff --git a/matrix_client/api.py b/matrix_client/api.py index 6c17f878..4463e532 100644 --- a/matrix_client/api.py +++ b/matrix_client/api.py @@ -744,3 +744,144 @@ def get_room_members(self, room_id): room_id (str): The room to get the member events for. """ return self._send("GET", "/rooms/{}/members".format(quote(room_id))) + + def create_group(self, localpart): + """Create a new group. + + Args: + localpart (str): The local part (the thing before the ':') of the + new group to be created. + """ + body = { + "localpart": localpart + } + return self._send("POST", "/create_group", body) + + def invite_user_to_group(self, group_id, user_id): + """Invite a user to a group. + + Args: + group_id (str): The group ID + user_id (str): The user ID of the invitee + """ + return self._send("PUT", "/groups/{}/admin/users/invite/{}".format( + quote(group_id), quote(user_id))) + + def kick_user_from_group(self, group_id, user_id): + """Kick a user from a group. + + Args: + group_id (str): The group ID + user_id (str): The user ID of the user to be kicked + """ + return self._send("PUT", "/groups/{}/admin/users/remove/{}".format( + quote(group_id), quote(user_id))) + + def add_room_to_group(self, group_id, room_id): + """Add a room to a group. + + Args: + group_id (str): The group ID + room_id (str): The room ID of the room to be added + """ + return self._send("PUT", "/groups/{}/admin/rooms/{}".format( + quote(group_id), quote(room_id))) + + def remove_room_from_group(self, group_id, room_id): + """Removes a room from a group. + + Args: + group_id (str): The group ID + room_id (str): The room ID of the room to be removed + """ + return self._send("DELETE", "/groups/{}/admin/rooms/{}".format( + quote(group_id), quote(room_id))) + + def update_group_profile(self, group_id, profile_data): + """Update the profile of a group. + + Args: + profile_data (dict): The request payload. + + | Includes all the data to be updated in the group profile. + + | name (string): The new name of the group + + | avatar_url (URL): A URL pointing to the new URL for the + | group's avatar. + + | short_description (string): The new short description of the + | group. + + | long_description (string): The new long description of the + | group. + group_id (str): The group ID + """ + return self._send("POST", "/groups/{}/profile".format(quote(group_id)), + profile_data) + + def get_group_profile(self, group_id): + """Retrieve the profile of a group. + + Args: + group_id (str): The group ID + """ + return self._send("GET", "/groups/{}/profile".format(quote(group_id))) + + def get_users_in_group(self, group_id): + """Retrieve the users in a group. + + Args: + group_id (str): The group ID + """ + return self._send("GET", "/groups/{}/users".format(quote(group_id))) + + def get_invited_users_in_group(self, group_id): + """Retrieve invitations in a group. + + Args: + group_id (str): The group ID + """ + return self._send("GET", "/groups/{}/invited_users".format( + quote(group_id))) + + def get_rooms_in_group(self, group_id): + """Retrieve rooms in a group. + + Args: + group_id (str): The group ID + """ + return self._send("GET", "/groups/{}/rooms".format(quote(group_id))) + + def accept_group_invitation(self, group_id): + """Accept an invitation to a group. + + Args: + group_id (str): The group ID + """ + return self._send("PUT", "/groups/{}/self/accept_invite".format( + quote(group_id))) + + def leave_group(self, group_id): + """Leave a group. + + Args: + group_id (str): The group ID + """ + return self._send("PUT", "/groups/{}/self/leave".format( + quote(group_id))) + + def publicise_group(self, group_id, make_public): + """Publicise or depublicise a group in your profile. + + Args: + group_id (str): The group ID + make_public (bool): Set to True to show this group in your profile + """ + return self._send("PUT", "/groups/{}/self/update_publicity".format( + quote(group_id)), {'publicise': make_public}) + + def get_joined_groups(self): + """Get the groups that the user has joined. + """ + return self._send("GET", "/joined_groups") diff --git a/matrix_client/client.py b/matrix_client/client.py index 3ea1dc03..bd9a64e6 100644 --- a/matrix_client/client.py +++ b/matrix_client/client.py @@ -15,6 +15,7 @@ from .api import MatrixHttpApi from .errors import MatrixRequestError, MatrixUnexpectedResponse from .room import Room +from .group import Group from .user import User from enum import Enum from threading import Thread @@ -140,6 +141,9 @@ def __init__(self, base_url, token=None, user_id=None, self.rooms = { # room_id: Room } + self.groups = { + # group_id: Group + } if token: self.user_id = user_id self._sync() @@ -263,6 +267,21 @@ def create_room(self, alias=None, is_public=False, invitees=()): response = self.api.create_room(alias, is_public, invitees) return self._mkroom(response["room_id"]) + def create_group(self, localpart): + """ Create a new group on the homeserver. + + Args: + localpart (str): The part before the ':' of the new room ID. + + Returns: + Group + + Raises: + MatrixRequestError + """ + response = self.api.create_group(localpart) + return self._mkgroup(response["group_id"]) + def join_room(self, room_id_or_alias): """ Join a room. @@ -290,6 +309,21 @@ def get_rooms(self): """ return self.rooms + def get_groups(self): + """ Return a dict of {group_id: Group object} that the user has joined. + + TODO: As soon as group joins / leaves come down the event stream, + polling is not necessary here anymore. + + Returns: + Group{}: Groups the user has joined. + """ + response = self.api.get_joined_groups() + for group_id in response["groups"]: + if group_id not in self.groups: + self._mkgroup(group_id) + return self.groups + def add_listener(self, callback, event_type=None): """ Add a listener that will send a callback when the client recieves an event. @@ -487,6 +521,10 @@ def upload(self, content, content_type): content="Upload failed: %s" % e ) + def _mkgroup(self, group_id): + self.groups[group_id] = Group(self, group_id) + return self.groups[group_id] + def _mkroom(self, room_id): self.rooms[room_id] = Room(self, room_id) return self.rooms[room_id] diff --git a/matrix_client/group.py b/matrix_client/group.py new file mode 100644 index 00000000..6db22f41 --- /dev/null +++ b/matrix_client/group.py @@ -0,0 +1,222 @@ +from .room import Room +from .errors import MatrixRequestError + + +class Group(object): + """ The Group class can be used to call group specific functions. + + WARNING: This class uses the unstable groups API. Therefore, it might + be broken or break at any time. + """ + + def __init__(self, client, group_id): + """ Create a blank Group object. + + NOTE: This should ideally be called from within the Client. + NOTE: This does not verify the group with the Home Server. + """ + if not group_id.startswith("+"): + raise ValueError("Group IDs start with +") + + if ":" not in group_id: + raise ValueError("Group IDs must have a domain component, seperated by a :") + + self.group_id = group_id + self.client = client + + def get_members(self): + """Query joined members of this group. + + WARNING: For now, every call to this method causes a request to be + made, hitting the server API. This will change once the groups API has + stabilized and events are received via the sync method. For now, please + take care not to overuse this method. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Returns: + [ user_id ]: List of user IDs of the users in the group. + """ + response = self.client.api.get_users_in_group(self.group_id) + return [event["user_id"] for event in response["chunk"]] + + def get_invited_users(self): + """Query users invited to this group. + + WARNING: For now, every call to this method causes a request to be + made, hitting the server API. This will change once the groups API has + stabilized and events are received via the sync method. For now, please + take care not to overuse this method. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Returns: + [ user_id ]: List of user IDs of the users invited to this the group. + """ + response = self.client.api.get_invited_users_in_group(self.group_id) + return [event["user_id"] for event in response["chunk"]] + + def invite_user(self, user_id): + """Invite a user to this group. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Args: + user_id (str): The user ID of a user to be invited. + + Returns: + boolean: The invitation was sent. + """ + try: + self.client.api.invite_user_to_group(self.group_id, user_id) + return True + except MatrixRequestError: + return False + + def kick_user(self, user_id): + """Kick a user from this group. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Args: + user_id (str): The user ID of the user to be kicked. + + Returns: + boolean: The user was kicked. + """ + try: + self.client.api.kick_user_from_group(self.group_id, user_id) + return True + except MatrixRequestError: + return False + + def add_room(self, room_id): + """Add a room to the group. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Args: + room_id (str): The room ID of the room to be added. + + Returns: + boolean: True if the room was added. + """ + if isinstance(room_id, Room): + room_id = room_id.room_id + + try: + self.client.api.add_room_to_group(self.group_id, room_id) + return True + except MatrixRequestError: + return False + + def remove_room(self, room_id): + """Remove a room from the group. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Args: + room_id (str): The room ID of the room to be removed. + + Returns: + boolean: True if the room was removed. + """ + if isinstance(room_id, Room): + room_id = room_id.room_id + + try: + self.client.api.remove_room_from_group(self.group_id, room_id) + return True + except MatrixRequestError: + return False + + def get_rooms(self): + """Get the rooms associated with this group. + + WARNING: For now, every call to this method causes a request to be + made, hitting the server API. This will change once the groups API has + stabilized and events are received via the sync method. For now, please + take care not to overuse this method. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Returns: + [ room_id ]: List of room IDs of the rooms in the group. + """ + response = self.client.api.get_rooms_in_group(self.group_id) + return [event["room_id"] for event in response["chunk"]] + + @property + def name(self): + """Gets the room's name. + + WARNING: For now, every call to this method causes a request to be + made, hitting the server API. This will change once the groups API has + stabilized and events are received via the sync method. For now, please + take care not to overuse this method. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Returns + str: The name of the group. + """ + return self.client.api.get_group_profile(self.group_id)["name"] + + @property + def short_description(self): + """Gets the room's short description. + + WARNING: For now, every call to this method causes a request to be + made, hitting the server API. This will change once the groups API has + stabilized and events are received via the sync method. For now, please + take care not to overuse this method. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Returns + str: The short description of the group. + """ + return self.client.api.get_group_profile(self.group_id)["short_description"] + + @property + def long_description(self): + """Gets the room's long description. + + WARNING: For now, every call to this method causes a request to be + made, hitting the server API. This will change once the groups API has + stabilized and events are received via the sync method. For now, please + take care not to overuse this method. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Returns + str: The long description of the group. + """ + return self.client.api.get_group_profile(self.group_id)["long_description"] + + @property + def avatar_url(self): + """Gets the room's avatar URL. + + WARNING: For now, every call to this method causes a request to be + made, hitting the server API. This will change once the groups API has + stabilized and events are received via the sync method. For now, please + take care not to overuse this method. + + WARNING: This method uses the unstable groups API. Therefore, it might + be broken or break at any time. + + Returns + str: The avatar URL of the group. + """ + return self.client.api.get_group_profile(self.group_id)["avatar_url"] diff --git a/samples/ListOwnGroups.py b/samples/ListOwnGroups.py new file mode 100644 index 00000000..74102a0c --- /dev/null +++ b/samples/ListOwnGroups.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +# List the groups we are in, including their users. +# Args: host:port username password +# Error Codes: +# 2 - Could not find the server. +# 3 - Bad URL Format. +# 4 - Bad username/password. +# 11 - Serverside Error + +import sys +import samples_common + +from matrix_client.client import MatrixClient +from matrix_client.api import MatrixRequestError +from requests.exceptions import MissingSchema + + +host, username, password = samples_common.get_user_details(sys.argv) + +client = MatrixClient(host) + +try: + client.login_with_password_no_sync(username, password) +except MatrixRequestError as e: + print(e) + if e.code == 403: + print("Bad username or password.") + sys.exit(4) + else: + print("Check your server details are correct.") + sys.exit(2) +except MissingSchema as e: + print("Bad URL format.") + print(e) + sys.exit(3) + +groups = client.get_groups() +if len(groups) == 0: + print("No groups joined") + +for group_id, group in groups.items(): + print("=== Group: {}".format(group_id)) + + print("Name: {}".format(group.name)) + print("Short Description: {}".format(group.short_description)) + print("Long Description: {}".format(group.long_description)) + print("Avatar URL: {}".format(group.avatar_url)) + + print("Members: ") + for user_id in group.get_members(): + print(" * {}".format(user_id)) + + print("Invited Users: ") + for user_id in group.get_invited_users(): + print(" * {}".format(user_id)) + + print("Rooms: ") + for room_id in group.get_rooms(): + print(" * {}".format(room_id)) diff --git a/test/api_test.py b/test/api_test.py index d32729b5..7a4334e7 100644 --- a/test/api_test.py +++ b/test/api_test.py @@ -1,4 +1,10 @@ import responses +import json +try: + from urllib import quote +except ImportError: + from urllib.parse import quote + from matrix_client import client @@ -79,3 +85,242 @@ def test_unban(self): req = responses.calls[0].request assert req.url == unban_url assert req.method == 'POST' + + +class TestGroupsApi: + cli = client.MatrixClient("http://example.com") + user_id = "@user:matrix.org" + room_id = "#foo:matrix.org" + localpart = "testgroup" + group_id = "+testgroup:matrix.org" + + @responses.activate + def test_create_group(self): + create_url = "http://example.com" \ + "/_matrix/client/r0/create_group" + body = '{"group_id": "+' + self.localpart + ':matrix.org"}' + responses.add(responses.POST, create_url, body=body) + + self.cli.api.create_group(self.localpart) + + req = responses.calls[0].request + resp = responses.calls[0].response + + assert req.url == create_url + assert req.method == 'POST' + assert resp.json()['group_id'] == "+" + self.localpart + ":matrix.org" + + @responses.activate + def test_invite_to_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/admin/users/invite/" + quote(self.user_id) + body = '{"state": "invite"}' + responses.add(responses.PUT, url, body=body) + + self.cli.api.invite_user_to_group(self.group_id, self.user_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'PUT' + + @responses.activate + def test_kick_from_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/admin/users/remove/" + quote(self.user_id) + body = '{}' + responses.add(responses.PUT, url, body=body) + + self.cli.api.kick_user_from_group(self.group_id, self.user_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'PUT' + + @responses.activate + def test_add_room_to_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/admin/rooms/" + quote(self.room_id) + body = '{}' + responses.add(responses.PUT, url, body=body) + + self.cli.api.add_room_to_group(self.group_id, self.room_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'PUT' + + @responses.activate + def test_remove_room_from_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/admin/rooms/" + quote(self.room_id) + body = '{}' + responses.add(responses.DELETE, url, body=body) + + self.cli.api.remove_room_from_group(self.group_id, self.room_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'DELETE' + + @responses.activate + def test_update_group_profile(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/profile" + body = '{}' + responses.add(responses.POST, url, body=body) + + profile_data = {"name": "New Name", "short_description": "Test Description"} + self.cli.api.update_group_profile(self.group_id, profile_data) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'POST' + + @responses.activate + def test_get_group_profile(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/profile" + body = '''{ + "name": "Group Name", + "avatar_url": "", + "short_description": "A one line, relatively short, description of the group", + "long_description": "A longer multi line description of the group" + }''' + responses.add(responses.GET, url, body=body) + + self.cli.api.get_group_profile(self.group_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'GET' + + @responses.activate + def test_get_users_in_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/users" + # FIXME This seems not to be stable yet! See the specs / specs proposal. + body = '''{ + "chunk": [ + {"user_id": "@user:matrix.org"} + ] + }''' + responses.add(responses.GET, url, body=body) + + self.cli.api.get_users_in_group(self.group_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'GET' + + @responses.activate + def test_get_invited_users_in_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/invited_users" + # FIXME This seems not to be stable yet! See the specs / specs proposal. + body = '''{ + "chunk": [ + {"user_id": "@user:matrix.org"} + ] + }''' + responses.add(responses.GET, url, body=body) + + self.cli.api.get_invited_users_in_group(self.group_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'GET' + + @responses.activate + def test_get_rooms_in_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/rooms" + body = '''{ + "chunk": [ + {"room_id": "#foo:matrix.org"} + ] + }''' + responses.add(responses.GET, url, body=body) + + self.cli.api.get_rooms_in_group(self.group_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'GET' + + @responses.activate + def test_accept_group_invitation(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/self/accept_invite" + body = '{}' + responses.add(responses.PUT, url, body=body) + + self.cli.api.accept_group_invitation(self.group_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'PUT' + + @responses.activate + def test_leave_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/self/leave" + body = '{}' + responses.add(responses.PUT, url, body=body) + + self.cli.api.leave_group(self.group_id) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'PUT' + + @responses.activate + def test_publicise_group(self): + url = "http://example.com" \ + "/_matrix/client/r0/groups/" + quote(self.group_id) + \ + "/self/update_publicity" + body = '{}' + responses.add(responses.PUT, url, body=body) + + self.cli.api.publicise_group(self.group_id, True) + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'PUT' + assert json.loads(req.body)['publicise'] + + @responses.activate + def test_get_joined_groups(self): + url = "http://example.com" \ + "/_matrix/client/r0/joined_groups" + body = '{}' + responses.add(responses.GET, url, body=body) + + self.cli.api.get_joined_groups() + + req = responses.calls[0].request + + assert req.url == url + assert req.method == 'GET'