Skip to content

Updates to slack-adapter #1559

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 13, 2021
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# --------------------------------------------------------------------------

from .about import __version__
from .slack_options import SlackAdapterOptions
from .slack_client_options import SlackClientOptions
from .slack_client import SlackClient
from .slack_adapter import SlackAdapter
from .slack_payload import SlackPayload
Expand All @@ -15,10 +15,11 @@
from .activity_resourceresponse import ActivityResourceResponse
from .slack_request_body import SlackRequestBody
from .slack_helper import SlackHelper
from .slack_adatper_options import SlackAdapterOptions

__all__ = [
"__version__",
"SlackAdapterOptions",
"SlackClientOptions",
"SlackClient",
"SlackAdapter",
"SlackPayload",
Expand All @@ -27,4 +28,5 @@
"ActivityResourceResponse",
"SlackRequestBody",
"SlackHelper",
"SlackAdapterOptions",
]
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .activity_resourceresponse import ActivityResourceResponse
from .slack_client import SlackClient
from .slack_helper import SlackHelper
from .slack_adatper_options import SlackAdapterOptions


class SlackAdapter(BotAdapter, ABC):
Expand All @@ -32,10 +33,12 @@ def __init__(
self,
client: SlackClient,
on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None,
options: SlackAdapterOptions = None,
):
super().__init__(on_turn_error)
self.slack_client = client
self.slack_logged_in = False
self.options = options if options else SlackAdapterOptions()
Copy link
Member

@axelsrz axelsrz Apr 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nitpicking but this could be changed to:
self.options = options or SlackAdapterOptions


async def send_activities(
self, context: TurnContext, activities: List[Activity]
Expand All @@ -62,7 +65,7 @@ async def send_activities(
if activity.type == ActivityTypes.message:
message = SlackHelper.activity_to_slack(activity)

slack_response = await self.slack_client.post_message_to_slack(message)
slack_response = await self.slack_client.post_message(message)

if slack_response and slack_response.status_code / 100 == 2:
resource_response = ActivityResourceResponse(
Expand Down Expand Up @@ -99,8 +102,8 @@ async def update_activity(self, context: TurnContext, activity: Activity):
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,
results = await self.slack_client.chat_update(
ts=message.ts, channel=message.channel, text=message.text,
)

if results.status_code / 100 != 2:
Expand Down Expand Up @@ -130,17 +133,17 @@ async def delete_activity(
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
await self.slack_client.chat_delete(
channel=reference.conversation.id, ts=reference.activity_id
)

async def continue_conversation(
self,
reference: ConversationReference,
callback: Callable,
bot_id: str = None,
bot_id: str = None, # pylint: disable=unused-argument
claims_identity: ClaimsIdentity = None,
audience: str = None,
audience: str = None, # pylint: disable=unused-argument
):
"""
Send a proactive message to a conversation.
Expand Down Expand Up @@ -203,15 +206,20 @@ async def process(self, req: Request, logic: Callable) -> Response:
self.slack_logged_in = True

body = await req.text()

if (
self.options.verify_incoming_requests
and not self.slack_client.verify_signature(req, body)
):
return SlackHelper.response(
req, 401, "Rejected due to mismatched header signature"
)

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
Expand All @@ -231,7 +239,9 @@ async def process(self, req: Request, logic: Callable) -> Response:
slack_body, self.slack_client
)
else:
raise Exception(f"Unknown Slack event type {slack_body.type}")
return SlackHelper.response(
req, 200, f"Unknown Slack event type {slack_body.type}"
)

context = TurnContext(self, activity)
await self.run_pipeline(context, logic)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 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):
self.verify_incoming_requests = True
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from slack.web.slack_response import SlackResponse

from botbuilder.schema import Activity
from botbuilder.adapters.slack import SlackAdapterOptions
from botbuilder.adapters.slack.slack_client_options import SlackClientOptions
from botbuilder.adapters.slack.slack_message import SlackMessage

POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage"
Expand All @@ -26,7 +26,7 @@ class SlackClient(WebClient):
Slack client that extends https://github.com/slackapi/python-slackclient.
"""

def __init__(self, options: SlackAdapterOptions):
def __init__(self, options: SlackClientOptions):
if not options or not options.slack_bot_token:
raise Exception("SlackAdapterOptions and bot_token are required")

Expand Down Expand Up @@ -374,19 +374,10 @@ async def files_upload_ex(

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.")
async def get_bot_user_identity(
self, activity: Activity # pylint: disable=unused-argument
) -> str:
return self.identity

def verify_signature(self, req: Request, body: str) -> bool:
timestamp = req.headers["X-Slack-Request-Timestamp"]
Expand All @@ -402,7 +393,7 @@ def verify_signature(self, req: Request, body: str) -> bool:

return computed_signature == received_signature

async def post_message_to_slack(self, message: SlackMessage) -> SlackResponse:
async def post_message(self, message: SlackMessage) -> SlackResponse:
if not message:
return None

Expand Down
Original file line number Diff line number Diff line change
@@ -1,53 +1,32 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.


class SlackAdapterOptions:
"""
Defines the implementation of the SlackAdapter options.
"""

def __init__(
self,
slack_verification_token: str,
slack_bot_token: str,
slack_client_signing_secret: str,
):
"""
Initializes a new instance of SlackAdapterOptions.

:param slack_verification_token: A token for validating the origin of incoming webhooks.
:type slack_verification_token: str
:param slack_bot_token: A token for a bot to work on a single workspace.
:type slack_bot_token: str
:param slack_client_signing_secret: The token used to validate that incoming webhooks originated from Slack.
:type slack_client_signing_secret: str
"""
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:
"""
Receives a Slack team ID and returns the bot token associated with that team. Required for multi-team apps.

:param team_id: The team ID.
:type team_id: str
:raises: :func:`NotImplementedError`
"""
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: The team ID.
:type team_id: str
:raises: :func:`NotImplementedError`
"""
raise NotImplementedError()
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.


class SlackClientOptions:
"""
Defines the implementation of the SlackClient options.
"""

def __init__(
self,
slack_verification_token: str,
slack_bot_token: str,
slack_client_signing_secret: str,
):
"""
Initializes a new instance of SlackClientOptions.

:param slack_verification_token: A token for validating the origin of incoming webhooks.
:type slack_verification_token: str
:param slack_bot_token: A token for a bot to work on a single workspace.
:type slack_bot_token: str
:param slack_client_signing_secret: The token used to validate that incoming webhooks originated from Slack.
:type slack_client_signing_secret: str
"""
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]
Original file line number Diff line number Diff line change
Expand Up @@ -124,24 +124,35 @@ def payload_to_activity(payload: SlackPayload) -> Activity:

activity = Activity(
channel_id="slack",
conversation=ConversationAccount(id=payload.channel.id, properties={}),
conversation=ConversationAccount(id=payload.channel["id"], properties={}),
from_property=ChannelAccount(
id=payload.message.bot_id if payload.message.bot_id else payload.user.id
id=payload.message.bot_id
if payload.message.bot_id
else payload.user["id"]
),
recipient=ChannelAccount(),
channel_data=payload,
text=None,
type=ActivityTypes.event,
value=payload,
)

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
if payload.actions:
action = payload.actions[0]

if action["type"] == "button":
activity.text = action["value"]
elif action["type"] == "select":
selected_option = action["selected_options"]
activity.text = selected_option["value"] if selected_option else None
elif action["type"] == "static_select":
activity.text = action["selected_options"]["value"]

if activity.text:
activity.type = ActivityTypes.message

return activity

Expand Down Expand Up @@ -176,26 +187,27 @@ async def event_to_activity(event: SlackEvent, client: SlackClient) -> Activity:
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)
activity.recipient.id = await client.get_bot_user_identity(activity=activity)

if event.thread_ts:
activity.conversation.properties["thread_ts"] = event.thread_ts

# 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
if not event.subtype:
activity.type = ActivityTypes.message
activity.text = event.text

activity.conversation.properties["channel_type"] = event.channel_type
activity.value = event
else:
activity.name = event.type
activity.value = event

return activity

Expand Down Expand Up @@ -226,9 +238,11 @@ async def command_to_activity(
channel_data=body,
text=body.text,
type=ActivityTypes.event,
name="Command",
value=body.command,
)

activity.recipient.id = await client.get_bot_user_by_team(activity)
activity.recipient.id = await client.get_bot_user_identity(activity)
activity.conversation.properties["team"] = body.team_id

return activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ def __init__(self, **kwargs):
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.
# 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.
attachments = kwargs.get("attachments")
if attachments is not None:
self.attachments = [
Attachment(**{x: att[x] for x in att if x in Attachment.attributes})
for att in kwargs.get("attachments")
Expand Down
Loading