From 984954ca2245481b4fb3273c550dabc7cf45e373 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Fri, 31 Jan 2020 14:45:42 -0600 Subject: [PATCH 1/4] Added botbuilder-adapters-slack --- .../botbuilder-adapters-slack/README.rst | 83 ++++ .../botbuilder/adapters/slack/__init__.py | 30 ++ .../botbuilder/adapters/slack/about.py | 14 + .../slack/activity_resourceresponse.py | 11 + .../adapters/slack/slack_adapter.py | 210 ++++++++ .../botbuilder/adapters/slack/slack_client.py | 449 ++++++++++++++++++ .../botbuilder/adapters/slack/slack_event.py | 33 ++ .../botbuilder/adapters/slack/slack_helper.py | 271 +++++++++++ .../adapters/slack/slack_message.py | 33 ++ .../adapters/slack/slack_options.py | 46 ++ .../adapters/slack/slack_payload.py | 27 ++ .../adapters/slack/slack_request_body.py | 36 ++ .../requirements.txt | 4 + libraries/botbuilder-adapters-slack/setup.cfg | 2 + libraries/botbuilder-adapters-slack/setup.py | 50 ++ 15 files changed, 1299 insertions(+) create mode 100644 libraries/botbuilder-adapters-slack/README.rst create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/activity_resourceresponse.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_message.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py create mode 100644 libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py create mode 100644 libraries/botbuilder-adapters-slack/requirements.txt create mode 100644 libraries/botbuilder-adapters-slack/setup.cfg create mode 100644 libraries/botbuilder-adapters-slack/setup.py diff --git a/libraries/botbuilder-adapters-slack/README.rst b/libraries/botbuilder-adapters-slack/README.rst new file mode 100644 index 000000000..a3813c8b3 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/README.rst @@ -0,0 +1,83 @@ + +================================= +BotBuilder-Adapters SDK for Python +================================= + +.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master + :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI + :align: right + :alt: Azure DevOps status for master branch +.. image:: https://badge.fury.io/py/botbuilder-dialogs.svg + :target: https://badge.fury.io/py/botbuilder-dialogs + :alt: Latest PyPI package version + +A dialog stack based conversation manager for Microsoft BotBuilder. + +How to Install +============== + +.. code-block:: python + + pip install botbuilder-dialogs + + +Documentation/Wiki +================== + +You can find more information on the botbuilder-python project by visiting our `Wiki`_. + +Requirements +============ + +* `Python >= 3.7.0`_ + + +Source Code +=========== +The latest developer version is available in a github repository: +https://github.com/Microsoft/botbuilder-python/ + + +Contributing +============ + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the `Microsoft Open Source Code of Conduct`_. +For more information see the `Code of Conduct FAQ`_ or +contact `opencode@microsoft.com`_ with any additional questions or comments. + +Reporting Security Issues +========================= + +Security issues and bugs should be reported privately, via email, to the Microsoft Security +Response Center (MSRC) at `secure@microsoft.com`_. You should +receive a response within 24 hours. If for some reason you do not, please follow up via +email to ensure we received your original message. Further information, including the +`MSRC PGP`_ key, can be found in +the `Security TechCenter`_. + +License +======= + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT_ License. + +.. _Wiki: https://github.com/Microsoft/botbuilder-python/wiki +.. _Python >= 3.7.0: https://www.python.org/downloads/ +.. _MIT: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt +.. _Microsoft Open Source Code of Conduct: https://opensource.microsoft.com/codeofconduct/ +.. _Code of Conduct FAQ: https://opensource.microsoft.com/codeofconduct/faq/ +.. _opencode@microsoft.com: mailto:opencode@microsoft.com +.. _secure@microsoft.com: mailto:secure@microsoft.com +.. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155 +.. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt + +.. `_ \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py new file mode 100644 index 000000000..8e67c98df --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py @@ -0,0 +1,30 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .about import __version__ +from .slack_options import SlackAdapterOptions +from .slack_client import SlackClient +from .slack_adapter import SlackAdapter +from .slack_payload import SlackPayload +from .slack_message import SlackMessage +from .slack_event import SlackEvent +from .activity_resourceresponse import ActivityResourceResponse +from .slack_request_body import SlackRequestBody +from .slack_helper import SlackHelper + +__all__ = [ + "__version__", + "SlackAdapterOptions", + "SlackClient", + "SlackAdapter", + "SlackPayload", + "SlackMessage", + "SlackEvent", + "ActivityResourceResponse", + "SlackRequestBody", + "SlackHelper" +] diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py new file mode 100644 index 000000000..2babae85d --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/about.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = "botbuilder-adapters-slack" +__version__ = ( + os.environ["packageVersion"] if "packageVersion" in os.environ else "4.7.1" +) +__uri__ = "https://www.github.com/Microsoft/botbuilder-python" +__author__ = "Microsoft" +__description__ = "Microsoft Bot Framework Bot Builder" +__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python." +__license__ = "MIT" diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/activity_resourceresponse.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/activity_resourceresponse.py new file mode 100644 index 000000000..e99b2edd9 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/activity_resourceresponse.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema import ResourceResponse, ConversationAccount + + +class ActivityResourceResponse(ResourceResponse): + def __init__(self, activity_id: str, conversation: ConversationAccount, **kwargs): + super().__init__(**kwargs) + self.activity_id = activity_id + self.conversation = conversation diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py new file mode 100644 index 000000000..93fac05b9 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -0,0 +1,210 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC +from typing import List, Callable, Awaitable + +from aiohttp.web_request import Request +from aiohttp.web_response import Response +from botframework.connector.auth import ClaimsIdentity +from botbuilder.core import conversation_reference_extension +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.schema import ( + Activity, + ResourceResponse, + ActivityTypes, + ConversationAccount, + ConversationReference, +) + +from .activity_resourceresponse import ActivityResourceResponse +from .slack_client import SlackClient +from .slack_helper import SlackHelper + + +class SlackAdapter(BotAdapter, ABC): + """ + BotAdapter that can handle incoming slack events. Incoming slack events are deserialized to an Activity + that is dispatch through the middleware and bot pipeline. + """ + + def __init__( + self, + client: SlackClient, + on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None, + ): + super().__init__(on_turn_error) + self.slack_client = client + self.slack_logged_in = False + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + """ + Standard BotBuilder adapter method to send a message from the bot to the messaging API. + + :param context: A TurnContext representing the current incoming message and environment. + :param activities: An array of outgoing activities to be sent back to the messaging API. + :return: An array of ResourceResponse objects containing the IDs that Slack assigned to the sent messages. + """ + + if not context: + raise Exception("TurnContext is required") + if not activities: + raise Exception("List[Activity] is required") + + responses = [] + + for activity in activities: + if activity.type == ActivityTypes.message: + message = SlackHelper.activity_to_slack(activity) + + slack_response = await self.slack_client.post_message_to_slack(message) + + if slack_response and slack_response.status_code / 100 == 2: + resource_response = ActivityResourceResponse( + id=slack_response.data["ts"], + activity_id=slack_response.data["ts"], + conversation=ConversationAccount( + id=slack_response.data["channel"] + ), + ) + + responses.append(resource_response) + + return responses + + async def update_activity(self, context: TurnContext, activity: Activity): + """ + Standard BotBuilder adapter method to update a previous message with new content. + + :param context: A TurnContext representing the current incoming message and environment. + :param activity: The updated activity in the form '{id: `id of activity to update`, ...}'. + :return: A resource response with the Id of the updated activity. + """ + + if not context: + raise Exception("TurnContext is required") + if not activity: + raise Exception("Activity is required") + if not activity.id: + raise Exception("Activity.id is required") + if not activity.conversation: + raise Exception("Activity.conversation is required") + + message = SlackHelper.activity_to_slack(activity) + results = await self.slack_client.update( + timestamp=message.ts, channel_id=message.channel, text=message.text, + ) + + if results.status_code / 100 != 2: + raise Exception(f"Error updating activity on slack: {results}") + + return ResourceResponse(id=activity.id) + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + """ + Standard BotBuilder adapter method to delete a previous message. + + :param context: A TurnContext representing the current incoming message and environment. + :param reference: An object in the form "{activityId: `id of message to delete`, + conversation: { id: `id of slack channel`}}". + """ + + if not context: + raise Exception("TurnContext is required") + if not reference: + raise Exception("ConversationReference is required") + if not reference.channel_id: + raise Exception("ConversationReference.channel_id is required") + if not context.activity.timestamp: + raise Exception("Activity.timestamp is required") + + await self.slack_client.delete_message( + channel_id=reference.channel_id, timestamp=context.activity.timestamp + ) + + async def continue_conversation( + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + ): + """ + Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. + Most _channels require a user to initiate a conversation with a bot before the bot can send activities + to the user. + :param bot_id: The application ID of the bot. This parameter is ignored in + single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter + which is multi-tenant aware. + :param reference: A reference to the conversation to continue. + :param callback: The method to call for the resulting bot turn. + :param claims_identity: + """ + + if not reference: + raise Exception("ConversationReference is required") + if not callback: + raise Exception("callback is required") + + request = TurnContext.apply_conversation_reference( + conversation_reference_extension.get_continuation_activity(reference), + reference, + ) + context = TurnContext(self, request) + + return await self.run_pipeline(context, callback) + + async def process(self, req: Request, logic: Callable) -> Response: + """ + Accept an incoming webhook request and convert it into a TurnContext which can be processed by the bot's logic. + + :param req: The aoihttp Request object + :param logic: The method to call for the resulting bot turn. + :return: The aoihttp Response + """ + if not req: + raise Exception("Request is required") + + if not self.slack_logged_in: + await self.slack_client.login_with_slack() + self.slack_logged_in = True + + body = await req.text() + slack_body = SlackHelper.deserialize_body(req.content_type, body) + + if slack_body.type == "url_verification": + return SlackHelper.response(req, 200, slack_body.challenge) + + if not self.slack_client.verify_signature(req, body): + text = "Rejected due to mismatched header signature" + return SlackHelper.response(req, 401, text) + + if ( + not self.slack_client.options.slack_verification_token + and slack_body.token != self.slack_client.options.slack_verification_token + ): + text = f"Rejected due to mismatched verificationToken:{body}" + return SlackHelper.response(req, 403, text) + + if slack_body.payload: + # handle interactive_message callbacks and block_actions + activity = SlackHelper.payload_to_activity(slack_body.payload) + elif slack_body.type == "event_callback": + activity = await SlackHelper.event_to_activity( + slack_body.event, self.slack_client + ) + elif slack_body.command: + activity = await SlackHelper.command_to_activity( + slack_body, self.slack_client + ) + else: + raise Exception(f"Unknown Slack event type {slack_body.type}") + + context = TurnContext(self, activity) + await self.run_pipeline(context, logic) + + return SlackHelper.response(req, 200) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py new file mode 100644 index 000000000..3facbcfb1 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py @@ -0,0 +1,449 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import hashlib +import hmac +import json +from io import IOBase +from typing import Union + +import aiohttp +from aiohttp.web_request import Request +from slack.web.client import WebClient +from slack.web.slack_response import SlackResponse + +from botbuilder.schema import Activity +from botbuilder.adapters.slack import SlackAdapterOptions +from botbuilder.adapters.slack.slack_message import SlackMessage + +POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage" +POST_EPHEMERAL_MESSAGE_URL = "https://slack.com/api/chat.postEphemeral" + + +class SlackClient(WebClient): + """ + Slack client that extends https://github.com/slackapi/python-slackclient. + """ + + def __init__(self, options: SlackAdapterOptions): + if not options or not options.slack_bot_token: + raise Exception("SlackAdapterOptions and bot_token are required") + + if ( + not options.slack_verification_token + and not options.slack_client_signing_secret + ): + warning = ( + "\n****************************************************************************************\n" + "* WARNING: Your bot is operating without recommended security mechanisms in place. *\n" + "* Initialize your adapter with a clientSigningSecret parameter to enable *\n" + "* verification that all incoming webhooks originate with Slack: *\n" + "* *\n" + "* adapter = new SlackAdapter({clientSigningSecret: }); *\n" + "* *\n" + "****************************************************************************************\n" + ">> Slack docs: https://api.slack.com/docs/verifying-requests-from-slack" + ) + raise Exception( + warning + + "Required: include a verificationToken or clientSigningSecret to verify incoming Events API webhooks" + ) + + super().__init__(token=options.slack_bot_token, run_async=True) + + self.options = options + self.identity = None + + async def login_with_slack(self): + if self.options.slack_bot_token: + self.identity = await self.test_auth() + elif ( + not self.options.slack_client_id + or not self.options.slack_client_secret + or not self.options.slack_redirect_uri + or not self.options.slack_scopes + ): + raise Exception( + "Missing Slack API credentials! Provide SlackClientId, SlackClientSecret, scopes and SlackRedirectUri " + "as part of the SlackAdapter options." + ) + + def is_logged_in(self): + return self.identity is not None + + async def test_auth(self) -> str: + auth = await self.auth_test() + return auth.data["user_id"] + + async def channels_list_ex(self, exclude_archived: bool = True) -> SlackResponse: + args = {"exclude_archived": "1" if exclude_archived else "0"} + return await self.channels_list(**args) + + async def users_counts(self) -> SlackResponse: + return await self.api_call("users.counts") + + async def im_history_ex( + self, + channel: str, + latest_timestamp: str = None, + oldest_timestamp: str = None, + count: int = None, + unreads: bool = None, + ) -> SlackResponse: + args = {} + if latest_timestamp: + args["latest"] = latest_timestamp + if oldest_timestamp: + args["oldest"] = oldest_timestamp + if count: + args["count"] = str(count) + if unreads: + args["unreads"] = "1" if unreads else "0" + + return await self.im_history(channel=channel, **args) + + async def files_info_ex( + self, file_id: str, page: int = None, count: int = None + ) -> SlackResponse: + args = {"count": str(count), "page": str(page)} + return await self.files_info(file=file_id, **args) + + async def files_list_ex( + self, + user_id: str = None, + date_from: str = None, + date_to: str = None, + count: int = None, + page: int = None, + types: [str] = None, + ) -> SlackResponse: + args = {} + + if user_id: + args["user"] = user_id + + if date_from: + args["ts_from"] = date_from + if date_to: + args["ts_to"] = date_to + + if count: + args["count"] = str(count) + if page: + args["page"] = str(page) + + if types: + args["types"] = ",".join(types) + + return await self.files_list(**args) + + async def groups_history_ex( + self, channel: str, latest: str = None, oldest: str = None, count: int = None + ) -> SlackResponse: + args = {} + + if latest: + args["latest"] = latest + if oldest: + args["oldest"] = oldest + + if count: + args["count"] = count + + return await self.groups_history(channel=channel, **args) + + async def groups_list_ex(self, exclude_archived: bool = True) -> SlackResponse: + args = {"exclude_archived": "1" if exclude_archived else "0"} + return await self.groups_list(**args) + + async def get_preferences(self) -> SlackResponse: + return await self.api_call("users.prefs.get", http_verb="GET") + + async def stars_list_ex( + self, user: str = None, count: int = None, page: int = None + ) -> SlackResponse: + args = {} + + if user: + args["user"] = user + if count: + args["count"] = str(count) + if page: + args["page"] = str(page) + + return await self.stars_list(**args) + + async def groups_close(self, channel: str) -> SlackResponse: + args = {"channel": channel} + return await self.api_call("groups.close", params=args) + + async def chat_post_ephemeral_ex( + self, + channel: str, + text: str, + target_user: str, + parse: str = None, + link_names: bool = False, + attachments: [str] = None, + as_user: bool = False, + ) -> SlackResponse: + args = { + "text": text, + "link_names": "1" if link_names else "0", + "as_user": "1" if as_user else "0", + } + + if parse: + args["parse"] = parse + + # TODO: attachments (see PostEphemeralMessageAsync) + # See: https://api.slack.com/messaging/composing/layouts#attachments + # See: https://github.com/Inumedia/SlackAPI/blob/master/SlackAPI/Attachment.cs + + return await self.chat_postEphemeral(channel=channel, user=target_user, **args) + + async def chat_post_message_ex( + self, + channel: str, + text: str, + bot_name: str = None, + parse: str = None, + link_names: bool = False, + blocks: [str] = None, + attachments: [str] = None, + unfurl_links: bool = False, + icon_url: str = None, + icon_emoji: str = None, + as_user: bool = False, + ) -> SlackResponse: + args = { + "text": text, + "link_names": "1" if link_names else "0", + "as_user": "1" if as_user else "0", + } + + if bot_name: + args["username"] = bot_name + + if parse: + args["parse"] = parse + + if unfurl_links: + args["unfurl_links"] = "1" if unfurl_links else "0" + + if icon_url: + args["icon_url"] = icon_url + + if icon_emoji: + args["icon_emoji"] = icon_emoji + + # TODO: blocks and attachments (see PostMessageAsync) + # the blocks and attachments are combined into a single dict + # See: https://api.slack.com/messaging/composing/layouts#attachments + # See: https://github.com/Inumedia/SlackAPI/blob/master/SlackAPI/Attachment.cs + + return await self.chat_postMessage(channel=channel, **args) + + async def search_all_ex( + self, + query: str, + sorting: str = None, + direction: str = None, + enable_highlights: bool = False, + count: int = None, + page: int = None, + ) -> SlackResponse: + args = {"highlight": "1" if enable_highlights else "0"} + + if sorting: + args["sort"] = sorting + + if direction: + args["sort_dir"] = direction + + if count: + args["count"] = str(count) + + if page: + args["page"] = str(page) + + return await self.search_all(query=query, **args) + + async def search_files_ex( + self, + query: str, + sorting: str = None, + direction: str = None, + enable_highlights: bool = False, + count: int = None, + page: int = None, + ) -> SlackResponse: + args = {"highlight": "1" if enable_highlights else "0"} + + if sorting: + args["sort"] = sorting + + if direction: + args["sort_dir"] = direction + + if count: + args["count"] = str(count) + + if page: + args["page"] = str(page) + + return await self.search_files(query=query, **args) + + async def search_messages_ex( + self, + query: str, + sorting: str = None, + direction: str = None, + enable_highlights: bool = False, + count: int = None, + page: int = None, + ) -> SlackResponse: + args = {"highlight": "1" if enable_highlights else "0"} + + if sorting: + args["sort"] = sorting + + if direction: + args["sort_dir"] = direction + + if count: + args["count"] = str(count) + + if page: + args["page"] = str(page) + + return await self.search_messages(query=query, **args) + + async def chat_update_ex( + self, + timestamp: str, + channel: str, + text: str, + bot_name: str = None, + parse: str = None, + link_names: bool = False, + attachments: [str] = None, + as_user: bool = False, + ): + args = { + "text": text, + "link_names": "1" if link_names else "0", + "as_user": "1" if as_user else "0", + } + + if bot_name: + args["username"] = bot_name + + if parse: + args["parse"] = parse + + # TODO: attachments (see PostEphemeralMessageAsync) + # See: https://api.slack.com/messaging/composing/layouts#attachments + # See: https://github.com/Inumedia/SlackAPI/blob/master/SlackAPI/Attachment.cs + + return await self.chat_update(channel=channel, ts=timestamp) + + async def files_upload_ex( + self, + file: Union[str, IOBase] = None, + content: str = None, + channels: [str] = None, + title: str = None, + initial_comment: str = None, + file_type: str = None, + ): + args = {} + + if channels: + args["channels"] = ",".join(channels) + + if title: + args["title"] = title + + if initial_comment: + args["initial_comment"] = initial_comment + + if file_type: + args["filetype"] = file_type + + return await self.files_upload(file=file, content=content, **args) + + async def get_bot_user_by_team(self, activity: Activity) -> str: + if self.identity: + return self.identity + + if not activity.conversation.properties["team"]: + return None + + user = await self.options.get_bot_user_by_team( + activity.conversation.properties["team"] + ) + if user: + return user + raise Exception("Missing credentials for team.") + + def verify_signature(self, req: Request, body: str) -> bool: + timestamp = req.headers["X-Slack-Request-Timestamp"] + message = ":".join(["v0", timestamp, body]) + + computed_signature = "V0=" + hmac.new( + bytes(self.options.slack_client_signing_secret, "utf-8"), + msg=bytes(message, "utf-8"), + digestmod=hashlib.sha256, + ).hexdigest().upper().replace("-", "") + + received_signature = req.headers["X-Slack-Signature"].upper() + + return computed_signature == received_signature + + async def post_message_to_slack(self, message: SlackMessage) -> SlackResponse: + if not message: + return None + + request_content = { + "token": self.options.slack_bot_token, + "channel": message.channel, + "text": message.text, + } + + if message.thread_ts: + request_content["thread_ts"] = message.thread_ts + + if message.blocks: + request_content["blocks"] = json.dumps(message.blocks) + + session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30),) + + http_verb = "POST" + api_url = POST_EPHEMERAL_MESSAGE_URL if message.ephemeral else POST_MESSAGE_URL + req_args = {"data": request_content} + + async with session.request(http_verb, api_url, **req_args) as res: + response_content = {} + try: + response_content = await res.json() + except aiohttp.ContentTypeError: + pass + + response_data = { + "data": response_content, + "headers": res.headers, + "status_code": res.status, + } + + data = { + "client": self, + "http_verb": http_verb, + "api_url": api_url, + "req_args": req_args, + } + response = SlackResponse(**{**data, **response_data}).validate() + + await session.close() + + return response diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py new file mode 100644 index 000000000..689b0b25c --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_event.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.adapters.slack.slack_message import SlackMessage + + +class SlackEvent: + """ + Wrapper class for an incoming slack event. + """ + + def __init__(self, **kwargs): + self.client_msg_id = kwargs.get("client_msg_id") + self.type = kwargs.get("type") + self.subtype = kwargs.get("subtype") + self.text = kwargs.get("text") + self.ts = kwargs.get("ts") # pylint: disable=invalid-name + self.team = kwargs.get("team") + self.channel = kwargs.get("channel") + self.channel_id = kwargs.get("channel_id") + self.event_ts = kwargs.get("event_ts") + self.channel_type = kwargs.get("channel_type") + self.thread_ts = kwargs.get("thread_ts") + self.user = kwargs.get("user") + self.user_id = kwargs.get("user_id") + self.bot_id = kwargs.get("bot_id") + self.actions: [str] = kwargs.get("actions") + self.item = kwargs.get("item") + self.item_channel = kwargs.get("item_channel") + self.files: [] = kwargs.get("files") + self.message = ( + None if "message" not in kwargs else SlackMessage(**kwargs.get("message")) + ) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py new file mode 100644 index 000000000..cb2390d52 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py @@ -0,0 +1,271 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import urllib.parse + +from aiohttp.web_request import Request +from aiohttp.web_response import Response +from slack.web.classes.attachments import Attachment + +from botbuilder.schema import ( + Activity, + ConversationAccount, + ChannelAccount, + ActivityTypes, +) + +from .slack_message import SlackMessage +from .slack_client import SlackClient +from .slack_event import SlackEvent +from .slack_payload import SlackPayload +from .slack_request_body import SlackRequestBody + + +class SlackHelper: + @staticmethod + def activity_to_slack(activity: Activity) -> SlackMessage: + """ + Formats a BotBuilder activity into an outgoing Slack message. + :param activity: A BotBuilder Activity object. + :return: A Slack message object with {text, attachments, channel, thread ts} as well + as any fields found in activity.channelData + """ + + if not activity: + raise Exception("Activity required") + + # use ChannelData if available + if activity.channel_data: + message = activity.channel_data + else: + message = SlackMessage( + ts=activity.timestamp, + text=activity.text, + channel=activity.conversation.id, + ) + + if activity.attachments: + attachments = [] + for att in activity.attachments: + if att.name == "blocks": + message.blocks = att.content + else: + new_attachment = Attachment( + author_name=att.name, thumb_url=att.thumbnail_url, + ) + attachments.append(new_attachment) + + if attachments: + message.attachments = attachments + + if ( + activity.conversation.properties + and "thread_ts" in activity.conversation.properties + ): + message.thread_ts = activity.conversation.properties["thread_ts"] + + if message.ephemeral: + message.user = activity.recipient.id + + if ( + message.icon_url + or not (message.icons and message.icons.status_emoji) + or not message.username + ): + message.as_user = False + + return message + + @staticmethod + def response( # pylint: disable=unused-argument + req: Request, code: int, text: str = None, encoding: str = None + ) -> Response: + """ + Formats an aiohttp Response + + :param req: The original aoihttp Request + :param code: The HTTP result code to return + :param text: The text to return + :param encoding: The text encoding. Defaults to utf-8 + :return: The aoihttp Response + """ + + response = Response(status=code) + + if text: + response.content_type = "text/plain" + response.body = text.encode(encoding=encoding if encoding else "utf-8") + + return response + + @staticmethod + def payload_to_activity(payload: SlackPayload) -> Activity: + """ + Creates an activity based on the slack event payload. + + :param payload: The payload of the slack event. + :return: An activity containing the event data. + """ + + if not payload: + raise Exception("payload is required") + + activity = Activity( + channel_id="slack", + conversation=ConversationAccount(id=payload.channel.id, properties={}), + from_property=ChannelAccount( + id=payload.message.bot_id if payload.message.bot_id else payload.user.id + ), + recipient=ChannelAccount(), + channel_data=payload, + text=None, + type=ActivityTypes.event, + ) + + if payload.thread_ts: + activity.conversation.properties["thread_ts"] = payload.thread_ts + + if payload.actions and ( + payload.type == "block_actions" or payload.type == "interactive_message" + ): + activity.type = ActivityTypes.message + activity.text = payload.actions.value + + return activity + + @staticmethod + async def event_to_activity(event: SlackEvent, client: SlackClient) -> Activity: + """ + Creates an activity based on the slack event data. + + :param event: The data of the slack event. + :param client: The Slack client. + :return: An activity containing the event data. + """ + + if not event: + raise Exception("slack event is required") + + activity = Activity( + id=event.event_ts, + channel_id="slack", + conversation=ConversationAccount( + id=event.channel if event.channel else event.channel_id, properties={} + ), + from_property=ChannelAccount( + id=event.bot_id if event.bot_id else event.user_id + ), + recipient=ChannelAccount(id=None), + channel_data=event, + text=event.text, + type=ActivityTypes.event, + ) + + if event.thread_ts: + activity.conversation.properties["thread_ts"] = event.thread_ts + + if not activity.conversation.id: + if event.item and event.item_channel: + activity.conversation.id = event.item_channel + else: + activity.conversation.id = event.team + + activity.recipient.id = await client.get_bot_user_by_team(activity=activity) + + # If this is a message originating from a user, we'll mark it as such + # If this is a message from a bot (bot_id != None), we want to ignore it by + # leaving the activity type as Event. This will stop it from being included in dialogs, + # but still allow the Bot to act on it if it chooses (via ActivityHandler.on_event_activity). + # NOTE: This catches a message from ANY bot, including this bot. + # Note also, bot_id here is not the same as bot_user_id so we can't (yet) identify messages + # originating from this bot without doing an additional API call. + if event.type == "message" and not event.subtype and not event.bot_id: + activity.type = ActivityTypes.message + + return activity + + @staticmethod + async def command_to_activity( + body: SlackRequestBody, client: SlackClient + ) -> Activity: + """ + Creates an activity based on a slack event related to a slash command. + + :param body: The data of the slack event. + :param client: The Slack client. + :return: An activity containing the event data. + """ + + if not body: + raise Exception("body is required") + + activity = Activity( + id=body.trigger_id, + channel_id="slack", + conversation=ConversationAccount(id=body.channel_id, properties={}), + from_property=ChannelAccount(id=body.user_id), + recipient=ChannelAccount(id=None), + channel_data=body, + text=body.text, + type=ActivityTypes.event, + ) + + activity.recipient.id = await client.get_bot_user_by_team(activity) + activity.conversation.properties["team"] = body.team_id + + return activity + + @staticmethod + def query_string_to_dictionary(query: str) -> {}: + """ + Converts a query string to a dictionary with key-value pairs. + + :param query: The query string to convert. + :return: A dictionary with the query values. + """ + + values = {} + + if not query: + return values + + pairs = query.replace("+", "%20").split("&") + + for pair in pairs: + key_value = pair.split("=") + key = key_value[0] + value = urllib.parse.unquote(key_value[1]) + + values[key] = value + + return values + + @staticmethod + def deserialize_body(content_type: str, request_body: str) -> SlackRequestBody: + """ + Deserializes the request's body as a SlackRequestBody object. + + :param content_type: The content type of the body + :param request_body: The body of the request + :return: A SlackRequestBody object + """ + + if not request_body: + return None + + if content_type == "application/x-www-form-urlencoded": + request_dict = SlackHelper.query_string_to_dictionary(request_body) + elif content_type == "application/json": + request_dict = json.loads(request_body) + else: + raise Exception("Unknown request content type") + + if "command=%2F" in request_body: + return SlackRequestBody(**request_dict) + + if "payload=" in request_body: + payload = SlackPayload(**request_dict) + return SlackRequestBody(payload=payload, token=payload.token) + + return SlackRequestBody(**request_dict) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_message.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_message.py new file mode 100644 index 000000000..38a7e3297 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_message.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from slack.web.classes.attachments import Attachment +from slack.web.classes.blocks import Block + + +class SlackMessage: + def __init__(self, **kwargs): + self.ephemeral = kwargs.get("ephemeral") + self.as_user = kwargs.get("as_user") + self.icon_url = kwargs.get("icon_url") + self.icon_emoji = kwargs.get("icon_emoji") + self.thread_ts = kwargs.get("thread_ts") + self.user = kwargs.get("user") + self.channel = kwargs.get("channel") + self.text = kwargs.get("text") + self.team = kwargs.get("team") + self.ts = kwargs.get("ts") # pylint: disable=invalid-name + self.username = kwargs.get("username") + self.bot_id = kwargs.get("bot_id") + self.icons = kwargs.get("icons") + self.blocks: [Block] = kwargs.get("blocks") + + self.attachments = None + if "attachments" in kwargs: + # Create proper Attachment objects + # It would appear that we can get dict fields from the wire that aren't defined + # in the Attachment class. So only pass in known fields. + self.attachments = [ + Attachment(**{x: att[x] for x in att if x in Attachment.attributes}) + for att in kwargs.get("attachments") + ] diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py new file mode 100644 index 000000000..a855ea98a --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_options.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class SlackAdapterOptions: + """ + Class for defining implementation of the SlackAdapter Options. + """ + + def __init__( + self, + slack_verification_token: str, + slack_bot_token: str, + slack_client_signing_secret: str, + ): + """ + Initializes new instance of SlackAdapterOptions + :param slack_verification_token: A token for validating the origin of incoming webhooks. + :param slack_bot_token: A token for a bot to work on a single workspace. + :param slack_client_signing_secret: The token used to validate that incoming webhooks are originated from Slack. + """ + self.slack_verification_token = slack_verification_token + self.slack_bot_token = slack_bot_token + self.slack_client_signing_secret = slack_client_signing_secret + self.slack_client_id = None + self.slack_client_secret = None + self.slack_redirect_uri = None + self.slack_scopes = [str] + + async def get_token_for_team(self, team_id: str) -> str: + """ + A method that receives a Slack team id and returns the bot token associated with that team. Required for + multi-team apps. + :param team_id:Team ID. + :return:The bot token associated with the team. + """ + raise NotImplementedError() + + async def get_bot_user_by_team(self, team_id: str) -> str: + """ + A method that receives a Slack team id and returns the bot user id associated with that team. Required for + multi-team apps. + :param team_id:Team ID. + :return:The bot user id associated with that team. + """ + raise NotImplementedError() diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py new file mode 100644 index 000000000..5a8fd90eb --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_payload.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Optional, List + +from slack.web.classes.actions import Action + +from botbuilder.adapters.slack.slack_message import SlackMessage + + +class SlackPayload: + def __init__(self, **kwargs): + self.type: [str] = kwargs.get("type") + self.token: str = kwargs.get("token") + self.channel: str = kwargs.get("channel") + self.thread_ts: str = kwargs.get("thread_ts") + self.team: str = kwargs.get("team") + self.user: str = kwargs.get("user") + self.actions: Optional[List[Action]] = None + + if "message" in kwargs: + message = kwargs.get("message") + self.message = ( + message + if isinstance(message) is SlackMessage + else SlackMessage(**message) + ) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py new file mode 100644 index 000000000..7990555c7 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_request_body.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.adapters.slack.slack_event import SlackEvent +from botbuilder.adapters.slack.slack_payload import SlackPayload + + +class SlackRequestBody: + def __init__(self, **kwargs): + self.challenge = kwargs.get("challenge") + self.token = kwargs.get("token") + self.team_id = kwargs.get("team_id") + self.api_app_id = kwargs.get("api_app_id") + self.type = kwargs.get("type") + self.event_id = kwargs.get("event_id") + self.event_time = kwargs.get("event_time") + self.authed_users: [str] = kwargs.get("authed_users") + self.trigger_id = kwargs.get("trigger_id") + self.channel_id = kwargs.get("channel_id") + self.user_id = kwargs.get("user_id") + self.text = kwargs.get("text") + self.command = kwargs.get("command") + + self.payload: SlackPayload = None + if "payload" in kwargs: + payload = kwargs.get("payload") + self.payload = ( + payload + if isinstance(payload, SlackPayload) + else SlackPayload(**payload) + ) + + self.event: SlackEvent = None + if "event" in kwargs: + event = kwargs.get("event") + self.event = event if isinstance(event, SlackEvent) else SlackEvent(**event) diff --git a/libraries/botbuilder-adapters-slack/requirements.txt b/libraries/botbuilder-adapters-slack/requirements.txt new file mode 100644 index 000000000..4d6cdb67c --- /dev/null +++ b/libraries/botbuilder-adapters-slack/requirements.txt @@ -0,0 +1,4 @@ +aiohttp +pyslack +botbuilder-core>=4.7.1 +slackclient \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/setup.cfg b/libraries/botbuilder-adapters-slack/setup.cfg new file mode 100644 index 000000000..57e1947c4 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=0 \ No newline at end of file diff --git a/libraries/botbuilder-adapters-slack/setup.py b/libraries/botbuilder-adapters-slack/setup.py new file mode 100644 index 000000000..4ebe1cc57 --- /dev/null +++ b/libraries/botbuilder-adapters-slack/setup.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "botbuilder-schema>=4.7.0", + "botframework-connector>=4.7.0", + "botbuilder-core>=4.7.0", +] + +TEST_REQUIRES = ["aiounittest==1.3.0"] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "botbuilder", "adapters", "slack", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +with open(os.path.join(root, "README.rst"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords=["BotBuilderAdapters", "bots", "ai", "botframework", "botbuilder"], + long_description=long_description, + long_description_content_type="text/x-rst", + license=package_info["__license__"], + packages=[ + "botbuilder.adapters", + "botbuilder.adapters.slack", + ], + install_requires=REQUIRES + TEST_REQUIRES, + tests_require=TEST_REQUIRES, + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.7", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 5 - Production/Stable", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) From b5d85b3531d3e5c7b709be1e2539b081bc4ebf6c Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 3 Feb 2020 08:30:20 -0600 Subject: [PATCH 2/4] black fixes --- .../botbuilder/adapters/slack/__init__.py | 2 +- .../botbuilder/adapters/slack/slack_helper.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py index 8e67c98df..1ab395b75 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/__init__.py @@ -26,5 +26,5 @@ "SlackEvent", "ActivityResourceResponse", "SlackRequestBody", - "SlackHelper" + "SlackHelper", ] diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py index cb2390d52..bc5e471a3 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_helper.py @@ -78,7 +78,7 @@ def activity_to_slack(activity: Activity) -> SlackMessage: return message @staticmethod - def response( # pylint: disable=unused-argument + def response( # pylint: disable=unused-argument req: Request, code: int, text: str = None, encoding: str = None ) -> Response: """ From bb57ec974b368c16d0ee99004fdfb9bb14f8d679 Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 3 Feb 2020 08:38:09 -0600 Subject: [PATCH 3/4] setup.py black fixes --- libraries/botbuilder-adapters-slack/setup.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/setup.py b/libraries/botbuilder-adapters-slack/setup.py index 4ebe1cc57..d154572f2 100644 --- a/libraries/botbuilder-adapters-slack/setup.py +++ b/libraries/botbuilder-adapters-slack/setup.py @@ -32,10 +32,7 @@ long_description=long_description, long_description_content_type="text/x-rst", license=package_info["__license__"], - packages=[ - "botbuilder.adapters", - "botbuilder.adapters.slack", - ], + packages=["botbuilder.adapters", "botbuilder.adapters.slack",], install_requires=REQUIRES + TEST_REQUIRES, tests_require=TEST_REQUIRES, include_package_data=True, From 6e27696fca1395ac5b22064de332030a821a11da Mon Sep 17 00:00:00 2001 From: tracyboehrer Date: Mon, 3 Feb 2020 08:46:22 -0600 Subject: [PATCH 4/4] pylint fixes --- .../botbuilder/adapters/slack/slack_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py index 3facbcfb1..d5e645f3f 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_client.py @@ -184,7 +184,7 @@ async def chat_post_ephemeral_ex( target_user: str, parse: str = None, link_names: bool = False, - attachments: [str] = None, + attachments: [str] = None, # pylint: disable=unused-argument as_user: bool = False, ) -> SlackResponse: args = { @@ -209,8 +209,8 @@ async def chat_post_message_ex( bot_name: str = None, parse: str = None, link_names: bool = False, - blocks: [str] = None, - attachments: [str] = None, + blocks: [str] = None, # pylint: disable=unused-argument + attachments: [str] = None, # pylint: disable=unused-argument unfurl_links: bool = False, icon_url: str = None, icon_emoji: str = None, @@ -327,7 +327,7 @@ async def chat_update_ex( bot_name: str = None, parse: str = None, link_names: bool = False, - attachments: [str] = None, + attachments: [str] = None, # pylint: disable=unused-argument as_user: bool = False, ): args = {