diff --git a/integration_tests/web/test_admin_usergroups.py b/integration_tests/web/test_admin_usergroups.py index 3c27d3e77..466564af1 100644 --- a/integration_tests/web/test_admin_usergroups.py +++ b/integration_tests/web/test_admin_usergroups.py @@ -11,10 +11,7 @@ class TestWebClient(unittest.TestCase): - """Runs integration tests with real Slack API - - https://github.com/slackapi/python-slackclient/issues/378 - """ + """Runs integration tests with real Slack API""" def setUp(self): self.logger = logging.getLogger(__name__) diff --git a/integration_tests/web/test_calls.py b/integration_tests/web/test_calls.py new file mode 100644 index 000000000..15dbbab91 --- /dev/null +++ b/integration_tests/web/test_calls.py @@ -0,0 +1,128 @@ +import logging +import os +import unittest +import uuid + +from integration_tests.env_variable_names import SLACK_SDK_TEST_BOT_TOKEN +from integration_tests.helpers import async_test +from slack import WebClient +from slack.web.classes.blocks import CallBlock + +class TestWebClient(unittest.TestCase): + """Runs integration tests with real Slack API""" + + def setUp(self): + self.logger = logging.getLogger(__name__) + self.bot_token = os.environ[SLACK_SDK_TEST_BOT_TOKEN] + self.sync_client: WebClient = WebClient(token=self.bot_token) + self.async_client: WebClient = WebClient(token=self.bot_token, run_async=True) + + def tearDown(self): + pass + + def test_sync(self): + client = self.sync_client + user_id = list(filter( + lambda u: not u["deleted"] and "bot_id" not in u, + client.users_list(limit=50)["members"] + ))[0]["id"] + + new_call = client.calls_add( + external_unique_id=str(uuid.uuid4()), + join_url="https://www.example.com/calls/12345", + users=[ + { + "slack_id": user_id + }, + { + "external_id": "anon-111", + "avatar_url": "https://assets.brandfolder.com/pmix53-32t4so-a6439g/original/slackbot.png", + "display_name": "anonymous user 1", + } + ] + ) + self.assertIsNotNone(new_call) + call_id = new_call["call"]["id"] + + channel_message = client.chat_postMessage(channel="#random", blocks=[ + { + "type": "call", + "call_id": call_id, + } + ]) + self.assertIsNotNone(channel_message) + + channel_message = client.chat_postMessage(channel="#random", blocks=[ + CallBlock(call_id=call_id) + ]) + self.assertIsNotNone(channel_message) + + call_info = client.calls_info(id = call_id) + self.assertIsNotNone(call_info) + + new_participants = client.calls_participants_add(id=call_id, users=[ + { + "external_id": "anon-222", + "avatar_url": "https://assets.brandfolder.com/pmix53-32t4so-a6439g/original/slackbot.png", + "display_name": "anonymous user 2", + } + ]) + self.assertIsNotNone(new_participants) + + modified_call = client.calls_update(id=call_id, join_url="https://www.example.com/calls/99999") + self.assertIsNotNone(modified_call) + + ended_call = client.calls_end(id=call_id) + self.assertIsNotNone(ended_call) + + @async_test + async def test_async(self): + client = self.async_client + users = await client.users_list(limit=50) + user_id = list(filter( + lambda u: not u["deleted"] and "bot_id" not in u, + users["members"] + ))[0]["id"] + + new_call = await client.calls_add( + external_unique_id=str(uuid.uuid4()), + join_url="https://www.example.com/calls/12345", + users=[ + { + "slack_id": user_id + }, + { + "external_id": "anon-111", + "avatar_url": "https://assets.brandfolder.com/pmix53-32t4so-a6439g/original/slackbot.png", + "display_name": "anonymous user 1", + } + ] + ) + self.assertIsNotNone(new_call) + call_id = new_call["call"]["id"] + + channel_message = await client.chat_postMessage(channel="#random", blocks=[ + { + "type": "call", + "call_id": call_id, + } + ]) + self.assertIsNotNone(channel_message) + + call_info = await client.calls_info(id = call_id) + self.assertIsNotNone(call_info) + + new_participants = await client.calls_participants_add(id=call_id, users=[ + { + "external_id": "anon-222", + "avatar_url": "https://assets.brandfolder.com/pmix53-32t4so-a6439g/original/slackbot.png", + "display_name": "anonymous user 2", + } + ]) + self.assertIsNotNone(new_participants) + + modified_call = await client.calls_update(id=call_id, join_url="https://www.example.com/calls/99999") + self.assertIsNotNone(modified_call) + + ended_call = await client.calls_end(id=call_id) + self.assertIsNotNone(ended_call) diff --git a/slack/web/base_client.py b/slack/web/base_client.py index b927adb31..f08384ee4 100644 --- a/slack/web/base_client.py +++ b/slack/web/base_client.py @@ -28,6 +28,7 @@ import slack.version as slack_version from slack.errors import SlackRequestError from slack.web import convert_bool_to_0_or_1 +from slack.web.classes.blocks import Block from slack.web.slack_response import SlackResponse @@ -636,6 +637,31 @@ def validate_slack_signature( calculated_signature = f"v0={request_hash}" return hmac.compare_digest(calculated_signature, signature) + @staticmethod + def _parse_blocks(kwargs): + blocks = kwargs.get("blocks", None) + + def to_dict(b: Union[Dict, Block]): + if isinstance(b, Block): + return b.to_dict() + return b + + if blocks is not None and isinstance(blocks, list): + dict_blocks = [to_dict(b) for b in blocks] + kwargs.update({"blocks": dict_blocks}) + + @staticmethod + def _update_call_participants(kwargs, users: Union[str, List[Dict[str, str]]]): + if users is None: + return + + if isinstance(users, list): + kwargs.update({"users": json.dumps(users)}) + elif isinstance(users, str): + kwargs.update({"users": users}) + else: + raise SlackRequestError("users must be either str or List[Dict[str, str]]") + # https://api.slack.com/changelog/2020-01-deprecating-antecedents-to-the-conversations-api deprecated_method_prefixes_2020_01 = ["channels.", "groups.", "im.", "mpim."] diff --git a/slack/web/classes/blocks.py b/slack/web/classes/blocks.py index 259802884..7c2370775 100644 --- a/slack/web/classes/blocks.py +++ b/slack/web/classes/blocks.py @@ -1,7 +1,7 @@ import copy import logging import warnings -from typing import List, Optional, Set, Union +from typing import List, Optional, Set, Union, Dict from . import JsonObject, JsonValidator, show_unknown_key_warning from .elements import ( @@ -347,3 +347,30 @@ def __init__( self.external_id = external_id self.source = source + + +class CallBlock(Block): + type = "call" + + @property + def attributes(self) -> Set[str]: + return super().attributes.union({"call_id", "api_decoration_available", "call"}) + + def __init__( + self, + *, + call_id: str, + api_decoration_available: Optional[bool] = None, + call: Optional[Dict[str, Dict[str, any]]] = None, + block_id: Optional[str] = None, + **others: dict, + ): + """Displays a call information + https://api.slack.com/reference/block-kit/blocks#call + """ + super().__init__(type=self.type, block_id=block_id) + show_unknown_key_warning(self, others) + + self.call_id = call_id + self.api_decoration_available = api_decoration_available + self.call = call diff --git a/slack/web/client.py b/slack/web/client.py index 9286a4569..a1dbe848f 100644 --- a/slack/web/client.py +++ b/slack/web/client.py @@ -1,9 +1,8 @@ """A Python module for interacting with Slack's Web API.""" - import os from asyncio import Future from io import IOBase -from typing import Union, List, Optional +from typing import Union, List, Optional, Dict import slack.errors as e from slack.web.base_client import BaseClient, SlackResponse @@ -473,6 +472,62 @@ def bots_info(self, **kwargs) -> Union[Future, SlackResponse]: """Gets information about a bot user.""" return self.api_call("bots.info", http_verb="GET", params=kwargs) + def calls_add( + self, *, external_unique_id: str, join_url: str, **kwargs + ) -> Union[Future, SlackResponse]: + """Registers a new Call. + + Args: + external_unique_id (str): An ID supplied by the 3rd-party Call provider. + It must be unique across all Calls from that service. + e.g. '025169F6-E37A-4E62-BB54-7F93A0FC4C1F' + join_url (str): The URL required for a client to join the Call. + e.g. 'https://example.com/calls/1234567890' + """ + kwargs.update({"external_unique_id": external_unique_id, "join_url": join_url}) + self._update_call_participants(kwargs, kwargs.get("users", None)) + return self.api_call("calls.add", http_verb="POST", params=kwargs) + + def calls_end(self, *, id: str, **kwargs) -> Union[Future, SlackResponse]: + """Ends a Call. + + Args: + id (str): id returned when registering the call using the calls.add method. + """ + kwargs.update({"id": id}) + return self.api_call("calls.end", http_verb="POST", params=kwargs) + + def calls_info(self, *, id: str, **kwargs) -> Union[Future, SlackResponse]: + """Returns information about a Call. + + Args: + id (str): id returned when registering the call using the calls.add method. + """ + kwargs.update({"id": id}) + return self.api_call("calls.info", http_verb="POST", params=kwargs) + + def calls_participants_add( + self, *, id: str, users: Union[str, List[Dict[str, str]]], **kwargs + ) -> Union[Future, SlackResponse]: + """Registers new participants added to a Call. + + Args: + id (str): id returned when registering the call using the calls.add method. + users: (list): The list of users to add as participants in the Call. + """ + kwargs.update({"id": id}) + self._update_call_participants(kwargs, users) + return self.api_call("calls.participants.add", http_verb="POST", params=kwargs) + + def calls_update(self, *, id: str, **kwargs) -> Union[Future, SlackResponse]: + """Updates information about a Call. + + Args: + id (str): id returned by the calls.add method. + """ + kwargs.update({"id": id}) + return self.api_call("calls.update", http_verb="POST", params=kwargs) + def channels_archive( self, *, channel: str, **kwargs ) -> Union[Future, SlackResponse]: @@ -696,6 +751,7 @@ def chat_postEphemeral( e.g. [{"type": "section", "text": {"type": "plain_text", "text": "Hello world"}}] """ kwargs.update({"channel": channel, "user": user}) + self._parse_blocks(kwargs) return self.api_call("chat.postEphemeral", json=kwargs) def chat_postMessage( @@ -712,6 +768,7 @@ def chat_postMessage( e.g. [{"type": "section", "text": {"type": "plain_text", "text": "Hello world"}}] """ kwargs.update({"channel": channel}) + self._parse_blocks(kwargs) return self.api_call("chat.postMessage", json=kwargs) def chat_scheduleMessage( @@ -725,6 +782,7 @@ def chat_scheduleMessage( text (str): The message you'd like to send. e.g. 'Hello world' """ kwargs.update({"channel": channel, "post_at": post_at, "text": text}) + self._parse_blocks(kwargs) return self.api_call("chat.scheduleMessage", json=kwargs) def chat_unfurl( @@ -756,6 +814,7 @@ def chat_update( e.g. [{"type": "section", "text": {"type": "plain_text", "text": "Hello world"}}] """ kwargs.update({"channel": channel, "ts": ts}) + self._parse_blocks(kwargs) return self.api_call("chat.update", json=kwargs) def chat_scheduledMessages_list(self, **kwargs) -> Union[Future, SlackResponse]: diff --git a/tests/web/classes/test_blocks.py b/tests/web/classes/test_blocks.py index 870119367..812cd5b82 100644 --- a/tests/web/classes/test_blocks.py +++ b/tests/web/classes/test_blocks.py @@ -7,7 +7,7 @@ ContextBlock, DividerBlock, ImageBlock, - SectionBlock, InputBlock, FileBlock, Block, + SectionBlock, InputBlock, FileBlock, Block, CallBlock, ) from slack.web.classes.elements import ButtonElement, ImageElement, LinkButtonElement from slack.web.classes.objects import PlainTextObject, MarkdownTextObject @@ -602,3 +602,73 @@ def test_document(self): "source": "remote", } self.assertDictEqual(input, FileBlock(**input).to_dict()) + +# ---------------------------------------------- +# Call +# ---------------------------------------------- + +class CallBlockTests(unittest.TestCase): + def test_with_real_payload(self): + self.maxDiff = None + input = { + "type": "call", + "call_id": "R00000000", + "api_decoration_available": False, + "call": { + "v1": { + "id": "R00000000", + "app_id": "A00000000", + "app_icon_urls": { + "image_32": "https://www.example.com/", + "image_36": "https://www.example.com/", + "image_48": "https://www.example.com/", + "image_64": "https://www.example.com/", + "image_72": "https://www.example.com/", + "image_96": "https://www.example.com/", + "image_128": "https://www.example.com/", + "image_192": "https://www.example.com/", + "image_512": "https://www.example.com/", + "image_1024": "https://www.example.com/", + "image_original": "https://www.example.com/" + }, + "date_start": 12345, + "active_participants": [ + { + "slack_id": "U00000000" + }, + { + "slack_id": "U00000000", + "external_id": "", + "avatar_url": "https://www.example.com/", + "display_name": "" + } + ], + "all_participants": [ + { + "slack_id": "U00000000" + }, + { + "slack_id": "U00000000", + "external_id": "", + "avatar_url": "https://www.example.com/", + "display_name": "" + } + ], + "display_id": "", + "join_url": "https://www.example.com/", + "name": "", + "created_by": "U00000000", + "date_end": 12345, + "channels": [ + "C00000000" + ], + "is_dm_call": False, + "was_rejected": False, + "was_missed": False, + "was_accepted": False, + "has_ended": False, + "desktop_app_join_url": "https://www.example.com/" + } + } + } + self.assertDictEqual(input, CallBlock(**input).to_dict()) diff --git a/tests/web/mock_web_api_server.py b/tests/web/mock_web_api_server.py index eda4058e9..30ce97b5e 100644 --- a/tests/web/mock_web_api_server.py +++ b/tests/web/mock_web_api_server.py @@ -94,14 +94,22 @@ def _handle(self): page = request_body["cursor"] pattern = f"{pattern}_{page}" if pattern == "coverage": - ids = ["channels", "users", "channel_ids"] - if request_body: + if self.path.startswith("/calls."): for k, v in request_body.items(): - if k in ids: - if not re.compile(r"^[^,\[\]]+?,[^,\[\]]+$").match(v): - raise Exception( - f"The parameter {k} is not a comma-separated string value: {v}" - ) + if k == "users": + users = json.loads(v) + for u in users: + if "slack_id" not in u and "external_id" not in u: + raise Exception(f"User ({u}) is invalid value") + else: + ids = ["channels", "users", "channel_ids"] + if request_body: + for k, v in request_body.items(): + if k in ids: + if not re.compile(r"^[^,\[\]]+?,[^,\[\]]+$").match(v): + raise Exception( + f"The parameter {k} is not a comma-separated string value: {v}" + ) body = {"ok": True, "method": parsed_path.path.replace("/", "")} else: with open(f"tests/data/web_response_{pattern}.json") as file: diff --git a/tests/web/test_web_client_coverage.py b/tests/web/test_web_client_coverage.py index 4decdff95..a39110b8c 100644 --- a/tests/web/test_web_client_coverage.py +++ b/tests/web/test_web_client_coverage.py @@ -8,9 +8,9 @@ class TestWebClientCoverage(unittest.TestCase): - # as of May 12, 2020 + # 196 endpoints as of May 28, 2020 # Can be fetched by running `var methodNames = [].slice.call(document.getElementsByClassName('bold')).map(e => e.text);console.log(methodNames.toString());console.log(methodNames.length);` on https://api.slack.com/methods - all_api_methods = "admin.apps.approve,admin.apps.restrict,admin.apps.approved.list,admin.apps.requests.list,admin.apps.restricted.list,admin.conversations.setTeams,admin.emoji.add,admin.emoji.addAlias,admin.emoji.list,admin.emoji.remove,admin.emoji.rename,admin.inviteRequests.approve,admin.inviteRequests.deny,admin.inviteRequests.list,admin.inviteRequests.approved.list,admin.inviteRequests.denied.list,admin.teams.admins.list,admin.teams.create,admin.teams.list,admin.teams.owners.list,admin.teams.settings.info,admin.teams.settings.setDefaultChannels,admin.teams.settings.setDescription,admin.teams.settings.setDiscoverability,admin.teams.settings.setIcon,admin.teams.settings.setName,admin.usergroups.addChannels,admin.usergroups.listChannels,admin.usergroups.removeChannels,admin.users.assign,admin.users.invite,admin.users.list,admin.users.remove,admin.users.setAdmin,admin.users.setExpiration,admin.users.setOwner,admin.users.setRegular,admin.users.session.reset,api.test,apps.permissions.info,apps.permissions.request,apps.permissions.resources.list,apps.permissions.scopes.list,apps.permissions.users.list,apps.permissions.users.request,apps.uninstall,auth.revoke,auth.test,bots.info,chat.delete,chat.deleteScheduledMessage,chat.getPermalink,chat.meMessage,chat.postEphemeral,chat.postMessage,chat.scheduleMessage,chat.unfurl,chat.update,chat.scheduledMessages.list,conversations.archive,conversations.close,conversations.create,conversations.history,conversations.info,conversations.invite,conversations.join,conversations.kick,conversations.leave,conversations.list,conversations.members,conversations.open,conversations.rename,conversations.replies,conversations.setPurpose,conversations.setTopic,conversations.unarchive,dialog.open,dnd.endDnd,dnd.endSnooze,dnd.info,dnd.setSnooze,dnd.teamInfo,emoji.list,files.comments.delete,files.delete,files.info,files.list,files.revokePublicURL,files.sharedPublicURL,files.upload,files.remote.add,files.remote.info,files.remote.list,files.remote.remove,files.remote.share,files.remote.update,migration.exchange,oauth.access,oauth.token,oauth.v2.access,pins.add,pins.list,pins.remove,reactions.add,reactions.get,reactions.list,reactions.remove,reminders.add,reminders.complete,reminders.delete,reminders.info,reminders.list,rtm.connect,rtm.start,search.all,search.files,search.messages,stars.add,stars.list,stars.remove,team.accessLogs,team.billableInfo,team.info,team.integrationLogs,team.profile.get,usergroups.create,usergroups.disable,usergroups.enable,usergroups.list,usergroups.update,usergroups.users.list,usergroups.users.update,users.conversations,users.deletePhoto,users.getPresence,users.identity,users.info,users.list,users.lookupByEmail,users.setActive,users.setPhoto,users.setPresence,users.profile.get,users.profile.set,views.open,views.publish,views.push,views.update,channels.archive,channels.create,channels.history,channels.info,channels.invite,channels.join,channels.kick,channels.leave,channels.list,channels.mark,channels.rename,channels.replies,channels.setPurpose,channels.setTopic,channels.unarchive,groups.archive,groups.create,groups.createChild,groups.history,groups.info,groups.invite,groups.kick,groups.leave,groups.list,groups.mark,groups.open,groups.rename,groups.replies,groups.setPurpose,groups.setTopic,groups.unarchive,im.close,im.history,im.list,im.mark,im.open,im.replies,mpim.close,mpim.history,mpim.list,mpim.mark,mpim.open,mpim.replies".split( + all_api_methods = "admin.apps.approve,admin.apps.restrict,admin.apps.approved.list,admin.apps.requests.list,admin.apps.restricted.list,admin.conversations.setTeams,admin.emoji.add,admin.emoji.addAlias,admin.emoji.list,admin.emoji.remove,admin.emoji.rename,admin.inviteRequests.approve,admin.inviteRequests.deny,admin.inviteRequests.list,admin.inviteRequests.approved.list,admin.inviteRequests.denied.list,admin.teams.admins.list,admin.teams.create,admin.teams.list,admin.teams.owners.list,admin.teams.settings.info,admin.teams.settings.setDefaultChannels,admin.teams.settings.setDescription,admin.teams.settings.setDiscoverability,admin.teams.settings.setIcon,admin.teams.settings.setName,admin.usergroups.addChannels,admin.usergroups.listChannels,admin.usergroups.removeChannels,admin.users.assign,admin.users.invite,admin.users.list,admin.users.remove,admin.users.setAdmin,admin.users.setExpiration,admin.users.setOwner,admin.users.setRegular,admin.users.session.reset,api.test,apps.permissions.info,apps.permissions.request,apps.permissions.resources.list,apps.permissions.scopes.list,apps.permissions.users.list,apps.permissions.users.request,apps.uninstall,auth.revoke,auth.test,bots.info,calls.add,calls.end,calls.info,calls.update,calls.participants.add,chat.delete,chat.deleteScheduledMessage,chat.getPermalink,chat.meMessage,chat.postEphemeral,chat.postMessage,chat.scheduleMessage,chat.unfurl,chat.update,chat.scheduledMessages.list,conversations.archive,conversations.close,conversations.create,conversations.history,conversations.info,conversations.invite,conversations.join,conversations.kick,conversations.leave,conversations.list,conversations.members,conversations.open,conversations.rename,conversations.replies,conversations.setPurpose,conversations.setTopic,conversations.unarchive,dialog.open,dnd.endDnd,dnd.endSnooze,dnd.info,dnd.setSnooze,dnd.teamInfo,emoji.list,files.comments.delete,files.delete,files.info,files.list,files.revokePublicURL,files.sharedPublicURL,files.upload,files.remote.add,files.remote.info,files.remote.list,files.remote.remove,files.remote.share,files.remote.update,migration.exchange,oauth.access,oauth.token,oauth.v2.access,pins.add,pins.list,pins.remove,reactions.add,reactions.get,reactions.list,reactions.remove,reminders.add,reminders.complete,reminders.delete,reminders.info,reminders.list,rtm.connect,rtm.start,search.all,search.files,search.messages,stars.add,stars.list,stars.remove,team.accessLogs,team.billableInfo,team.info,team.integrationLogs,team.profile.get,usergroups.create,usergroups.disable,usergroups.enable,usergroups.list,usergroups.update,usergroups.users.list,usergroups.users.update,users.conversations,users.deletePhoto,users.getPresence,users.identity,users.info,users.list,users.lookupByEmail,users.setActive,users.setPhoto,users.setPresence,users.profile.get,users.profile.set,views.open,views.publish,views.push,views.update,channels.archive,channels.create,channels.history,channels.info,channels.invite,channels.join,channels.kick,channels.leave,channels.list,channels.mark,channels.rename,channels.replies,channels.setPurpose,channels.setTopic,channels.unarchive,groups.archive,groups.create,groups.createChild,groups.history,groups.info,groups.invite,groups.kick,groups.leave,groups.list,groups.mark,groups.open,groups.rename,groups.replies,groups.setPurpose,groups.setTopic,groups.unarchive,im.close,im.history,im.list,im.mark,im.open,im.replies,mpim.close,mpim.history,mpim.list,mpim.mark,mpim.open,mpim.replies".split( "," ) @@ -129,6 +129,31 @@ def test_coverage(self): self.api_methods_to_call.remove(method(team_id="T123", user_id="W123")["method"]) elif method_name == "admin_users_session_reset": self.api_methods_to_call.remove(method(user_id="W123")["method"]) + elif method_name == "calls_add": + self.api_methods_to_call.remove(method( + external_unique_id="unique-id", + join_url="https://www.example.com", + )["method"]) + elif method_name == "calls_end": + self.api_methods_to_call.remove(method(id="R111")["method"]) + elif method_name == "calls_info": + self.api_methods_to_call.remove(method(id="R111")["method"]) + elif method_name == "calls_participants_add": + self.api_methods_to_call.remove(method( + id="R111", + users=[ + { + "slack_id": "U1H77" + }, + { + "external_id": "54321678", + "display_name": "External User", + "avatar_url": "https://example.com/users/avatar1234.jpg" + } + ], + )["method"]) + elif method_name == "calls_update": + self.api_methods_to_call.remove(method(id="R111")["method"]) elif method_name == "chat_delete": self.api_methods_to_call.remove(method(channel="C123", ts="123.123")["method"]) elif method_name == "chat_deleteScheduledMessage":