From 19c5992f2a4e115659380c10d042f74c59a53a05 Mon Sep 17 00:00:00 2001 From: Bala Subrahmanyam Varanasi Date: Mon, 4 Mar 2024 10:33:11 +0530 Subject: [PATCH 1/5] feature(slack-app): implement support for slash commands --- llmstack/apps/handlers/slack_app.py | 40 ++++++++++++++++--- llmstack/apps/integration_configs.py | 2 + llmstack/apps/types/slack.py | 12 ++++++ .../components/apps/AppSlackConfigEditor.jsx | 36 +++++++++++++++++ 4 files changed, 85 insertions(+), 5 deletions(-) diff --git a/llmstack/apps/handlers/slack_app.py b/llmstack/apps/handlers/slack_app.py index f3ba032427b..3fed5f4632b 100644 --- a/llmstack/apps/handlers/slack_app.py +++ b/llmstack/apps/handlers/slack_app.py @@ -120,11 +120,10 @@ def _get_slack_app_session_id(self, slack_request_payload): def _get_input_data(self): slack_request_payload = self.request.data - - slack_message_type = slack_request_payload["type"] - if slack_message_type == "url_verification": + slack_request_type = slack_request_payload.get("type") + if slack_request_type == "url_verification": return {"input": {"challenge": slack_request_payload["challenge"]}} - elif slack_message_type == "event_callback": + elif slack_request_type == "event_callback": payload = process_slack_message_text( slack_request_payload["event"]["text"], ) @@ -148,6 +147,31 @@ def _get_input_data(self): ), }, } + # If the request is a command, then the payload will be in the form of a command + elif slack_request_payload.get("command"): + payload = process_slack_message_text( + slack_request_payload["text"], + ) + return { + "input": { + "text": slack_request_payload["text"], + "user": slack_request_payload["user_id"], + "slack_user_email": self._slack_user_email, + "token": slack_request_payload["token"], + "team_id": slack_request_payload["team_id"], + "api_app_id": slack_request_payload["api_app_id"], + "team": slack_request_payload["team_id"], + "channel": slack_request_payload["channel_id"], + "text-type": "command", + "ts": "", + **dict( + zip( + list(map(lambda x: x["name"], self.app_data["input_fields"])), + [payload] * len(self.app_data["input_fields"]), + ), + ), + }, + } else: raise Exception("Invalid Slack message type") @@ -203,8 +227,14 @@ def _is_app_accessible(self): is_valid_app_token = self.request.data.get("token") == self.slack_config.get("verification_token") is_valid_app_id = self.request.data.get("api_app_id") == self.slack_config.get("app_id") + is_valid_slash_command = False + if self.request.data.get("command") and self.slack_config.get("slash_command_name"): + request_slash_command = self.request.data.get("command") + configured_slash_command = self.slack_config.get("slash_command_name") + is_valid_slash_command = request_slash_command == configured_slash_command + # Validate that the app token, app ID and the request type are all valid. - if not (is_valid_app_token and is_valid_app_id and is_valid_request_type): + if not (is_valid_app_token and is_valid_app_id and (is_valid_request_type or is_valid_slash_command)): raise Exception("Invalid Slack request") # URL verification is allowed without any further checks diff --git a/llmstack/apps/integration_configs.py b/llmstack/apps/integration_configs.py index f44deb59488..508c6922e0b 100644 --- a/llmstack/apps/integration_configs.py +++ b/llmstack/apps/integration_configs.py @@ -51,6 +51,8 @@ class SlackIntegrationConfig(AppIntegrationConfig): config_type = "slack" is_encrypted = True app_id: str = "" + slash_command_name: str = "" + slash_command_description: str = "" bot_token: str = "" verification_token: str = "" signing_secret: str = "" diff --git a/llmstack/apps/types/slack.py b/llmstack/apps/types/slack.py index 39a55b9a8bc..c2b47906801 100644 --- a/llmstack/apps/types/slack.py +++ b/llmstack/apps/types/slack.py @@ -32,6 +32,18 @@ class SlackAppConfigSchema(BaseSchema): widget="password", description="Signing secret to verify the request from Slack. This secret is available at Features > Basic Information in your app page. More details https://api.slack.com/authentication/verifying-requests-from-slack", ) + slash_command_name: str = Field( + default="promptly", + title="Slash Command Name", + description="The name of the slash command that will be used to trigger the app. Slack commands must start with a slash, be all lowercase, and contain no spaces. Examples: /deploy, /ack, /weather. Ensure that the bot has access to the commands scope under Features > OAuth & Permissions.", + required=True, + ) + slash_command_description: str = Field( + title="Slash Command Description", + default="Promptly App", + description="The description of the slash command that will be used to trigger the app.", + required=True, + ) class SlackApp(AppTypeInterface[SlackAppConfigSchema]): diff --git a/llmstack/client/src/components/apps/AppSlackConfigEditor.jsx b/llmstack/client/src/components/apps/AppSlackConfigEditor.jsx index 441e272ad85..455e13bd4d3 100644 --- a/llmstack/client/src/components/apps/AppSlackConfigEditor.jsx +++ b/llmstack/client/src/components/apps/AppSlackConfigEditor.jsx @@ -32,6 +32,18 @@ const slackConfigSchema = { description: "Signing secret to verify the request from Slack. This secret is available at Features > Basic Information in your app page. More details https://api.slack.com/authentication/verifying-requests-from-slack", }, + slash_command_name: { + type: "string", + title: "Slash Command Name", + description: + "The name of the slash command that will be used to trigger the app. Slack commands must start with a slash, be all lowercase, and contain no spaces. Examples: /deploy, /ack, /weather. Ensure that the bot has access to the commands scope under Features > OAuth & Permissions.", + }, + slash_command_description: { + type: "string", + title: "Slash Command Description", + description: + "The description of the slash command that will be used to trigger the app.", + }, }, required: ["app_id", "bot_token", "verification_token", "signing_secret"], }; @@ -41,6 +53,14 @@ const slackConfigUISchema = { "ui:widget": "text", "ui:emptyValue": "", }, + slash_command_name: { + "ui:widget": "text", + "ui:emptyValue": "", + }, + slash_command_description: { + "ui:widget": "text", + "ui:emptyValue": "", + }, bot_token: { "ui:widget": "password", "ui:emptyValue": "", @@ -63,6 +83,8 @@ export function AppSlackConfigEditor(props) { function slackConfigValidate(formData, errors, uiSchema) { if ( formData.app_id || + formData.slash_command_name || + formData.slash_command_description || formData.bot_token || formData.verification_token || formData.signing_secret @@ -79,6 +101,20 @@ export function AppSlackConfigEditor(props) { if (!formData.signing_secret) { errors.signing_secret.addError("Signing Secret is required"); } + + const hasSlashCommand = Boolean( + formData.slash_command_name || formData.slash_command_description, + ); + if (hasSlashCommand) { + if (!formData.slash_command_name) { + errors.slash_command_name.addError("Slash Command Name is required"); + } + if (!formData.slash_command_description) { + errors.slash_command_description.addError( + "Slash Command Description is required", + ); + } + } } return errors; From e4411294effbd245c8e1200f42a00677f3633f31 Mon Sep 17 00:00:00 2001 From: Bala Subrahmanyam Varanasi Date: Mon, 4 Mar 2024 17:50:22 +0530 Subject: [PATCH 2/5] fix(slack-api): handle slash commands acknowledgents and invalid slash command error message --- llmstack/apps/apis.py | 9 +++ llmstack/apps/handlers/slack_app.py | 98 ++++++++++++++++++++++++----- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/llmstack/apps/apis.py b/llmstack/apps/apis.py index 7feb4487788..d937bb7ac6c 100644 --- a/llmstack/apps/apis.py +++ b/llmstack/apps/apis.py @@ -807,6 +807,15 @@ def run(self, request, uid, session_id=None, platform=None): ) response.is_async = True return response + if request.data.get("command"): + return DRFResponse( + result["message"], + status=200, + headers={ + "Content-Security-Policy": result["csp"] if "csp" in result else "frame-ancestors self", + }, + ) + response_body = {k: v for k, v in result.items() if k != "csp"} response_body["_id"] = request_uuid return DRFResponse( diff --git a/llmstack/apps/handlers/slack_app.py b/llmstack/apps/handlers/slack_app.py index 3fed5f4632b..7e7fb1c28df 100644 --- a/llmstack/apps/handlers/slack_app.py +++ b/llmstack/apps/handlers/slack_app.py @@ -54,6 +54,21 @@ def __init__(self, *args, **kwargs): self._slack_user = {} self._slack_user_email = "" + # the request type should be either url_verification or event_callback + self._request_type = self.request.data.get("type") + self._is_valid_request_type = self._request_type in ["url_verification", "event_callback"] + self._is_valid_app_token = self.request.data.get("token") == self.slack_config.get("verification_token") + self._is_valid_app_id = self.request.data.get("api_app_id") == self.slack_config.get("app_id") + + self._request_slash_command = self.request.data.get("command") + self._configured_slash_command = self.slack_config.get("slash_command_name") + + is_valid_slash_command = False + if self._request_slash_command and self._configured_slash_command: + is_valid_slash_command = self._request_slash_command == self._configured_slash_command + + self._is_valid_slash_command = is_valid_slash_command + def app_init(self): self.slack_config = ( SlackIntegrationConfig().from_dict( @@ -220,30 +235,33 @@ def _is_app_accessible(self): ): raise Exception("Invalid Slack request") - request_type = self.request.data.get("type") + if not self._is_valid_app_token: + raise Exception("Invalid App Token") - # the request type should be either url_verification or event_callback - is_valid_request_type = request_type in ["url_verification", "event_callback"] - is_valid_app_token = self.request.data.get("token") == self.slack_config.get("verification_token") - is_valid_app_id = self.request.data.get("api_app_id") == self.slack_config.get("app_id") + elif not self._is_valid_app_id: + raise Exception("Invalid App ID") - is_valid_slash_command = False - if self.request.data.get("command") and self.slack_config.get("slash_command_name"): - request_slash_command = self.request.data.get("command") - configured_slash_command = self.slack_config.get("slash_command_name") - is_valid_slash_command = request_slash_command == configured_slash_command + elif self._request_slash_command and not self._is_valid_slash_command: + raise Exception("Invalid Slash Command") + + elif self._request_type and not self._is_valid_request_type: + raise Exception("Invalid Slack request type. Only url_verification and event_callback are allowed.") # Validate that the app token, app ID and the request type are all valid. - if not (is_valid_app_token and is_valid_app_id and (is_valid_request_type or is_valid_slash_command)): + elif not ( + self._is_valid_app_token + and self._is_valid_app_id + and (self._is_valid_request_type or self._is_valid_slash_command) + ): raise Exception("Invalid Slack request") # URL verification is allowed without any further checks - if request_type == "url_verification": + if self._request_type == "url_verification": return True # Verify the request is coming from the app we expect and the event # type is app_mention - elif request_type == "event_callback": + elif self._request_type == "event_callback": event_data = self.request.data.get("event") or {} event_type = event_data.get("type") channel_type = event_data.get("channel_type") @@ -255,7 +273,7 @@ def _is_app_accessible(self): # Only allow direct messages from users and not from bots if channel_type == "im" and "subtype" not in event_data and "bot_id" not in event_data: return True - raise Exception("Invalid Slack request") + raise Exception("Invalid Slack event_callback request") return super()._is_app_accessible() @@ -401,3 +419,55 @@ def _get_actor_configs( self._get_bookkeeping_actor_config(processor_configs), ) return actor_configs + + def run_app(self): + # Check if the app access permissions are valid + self._is_app_accessible() + + csp = self._get_csp() + + template = convert_template_vars_from_legacy_format( + self.app_data["output_template"].get( + "markdown", + "", + ) + if self.app_data and "output_template" in self.app_data + else self.app.output_template.get( + "markdown", + "", + ), + ) + processor_actor_configs, processor_configs = self._get_processor_actor_configs() + actor_configs = self._get_actor_configs( + template, + processor_configs, + processor_actor_configs, + ) + + if self.app.type.slug == "agent": + self._start_agent( + self._get_input_data(), + self.app_session, + actor_configs, + csp, + template, + processor_configs, + ) + else: + self._start( + self._get_input_data(), + self.app_session, + actor_configs, + csp, + template, + ) + + message = "" + if self._is_valid_slash_command: + message = f"Processing the Command - `{self.request.data.get('command')} {self.request.data.get('text')}`" + elif self._request_slash_command and not self._is_valid_slash_command: + message = f"Invalid Slack Command - `{self.request.data.get('command')}`" + + return { + "message": message, + } From 1de75f79f38fd08c42fa76584f5d0068406f889c Mon Sep 17 00:00:00 2001 From: Bala Subrahmanyam Varanasi Date: Mon, 4 Mar 2024 18:23:29 +0530 Subject: [PATCH 3/5] fix(slack-api): handle slash command errors and refactor code for readability --- llmstack/apps/handlers/slack_app.py | 44 ++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/llmstack/apps/handlers/slack_app.py b/llmstack/apps/handlers/slack_app.py index 7e7fb1c28df..7aa8c5710c5 100644 --- a/llmstack/apps/handlers/slack_app.py +++ b/llmstack/apps/handlers/slack_app.py @@ -61,11 +61,14 @@ def __init__(self, *args, **kwargs): self._is_valid_app_id = self.request.data.get("api_app_id") == self.slack_config.get("app_id") self._request_slash_command = self.request.data.get("command") + self._request_slash_command_text = self.request.data.get("text") self._configured_slash_command = self.slack_config.get("slash_command_name") is_valid_slash_command = False if self._request_slash_command and self._configured_slash_command: - is_valid_slash_command = self._request_slash_command == self._configured_slash_command + is_valid_slash_command = ( + self._request_slash_command == self._configured_slash_command and self._request_slash_command_text + ) self._is_valid_slash_command = is_valid_slash_command @@ -226,6 +229,8 @@ def _get_slack_processor_actor_configs(self, input_data): ) def _is_app_accessible(self): + error_message = "" + if ( self.request.headers.get( "X-Slack-Request-Timestamp", @@ -233,19 +238,25 @@ def _is_app_accessible(self): is None or self.request.headers.get("X-Slack-Signature") is None ): - raise Exception("Invalid Slack request") + error_message = "Invalid Slack request" - if not self._is_valid_app_token: - raise Exception("Invalid App Token") + elif not self._is_valid_app_token: + error_message = "Invalid App Token" elif not self._is_valid_app_id: - raise Exception("Invalid App ID") + error_message = "Invalid App ID" elif self._request_slash_command and not self._is_valid_slash_command: - raise Exception("Invalid Slash Command") + error_message = f"Invalid Slack Command - `{self.request.data.get('command')}`" + + elif self._request_type and not self._is_valid_request_type: + error_message = "Invalid Slack request type. Only url_verification and event_callback are allowed." + + if self._request_slash_command and not self._request_slash_command_text: + error_message = f"Invalid Slash Command arguments. Command: `{self.request.data.get('command')}`. Arguments: `{self.request.data.get('text') or '-'}`" elif self._request_type and not self._is_valid_request_type: - raise Exception("Invalid Slack request type. Only url_verification and event_callback are allowed.") + error_message = f"Invalid Slack event request type - `{self._request_type}`" # Validate that the app token, app ID and the request type are all valid. elif not ( @@ -253,7 +264,10 @@ def _is_app_accessible(self): and self._is_valid_app_id and (self._is_valid_request_type or self._is_valid_slash_command) ): - raise Exception("Invalid Slack request") + error_message = "Invalid Slack request" + + if error_message: + raise Exception(error_message) # URL verification is allowed without any further checks if self._request_type == "url_verification": @@ -422,7 +436,11 @@ def _get_actor_configs( def run_app(self): # Check if the app access permissions are valid - self._is_app_accessible() + + try: + self._is_app_accessible() + except Exception as e: + return {"message": f"{str(e)}"} csp = self._get_csp() @@ -462,12 +480,6 @@ def run_app(self): template, ) - message = "" - if self._is_valid_slash_command: - message = f"Processing the Command - `{self.request.data.get('command')} {self.request.data.get('text')}`" - elif self._request_slash_command and not self._is_valid_slash_command: - message = f"Invalid Slack Command - `{self.request.data.get('command')}`" - return { - "message": message, + "message": f"Processing the Command - `{self.request.data.get('command')} {self.request.data.get('text')}`", } From e997188d73a70ae25fae08bac4767a1f2fcda73a Mon Sep 17 00:00:00 2001 From: Bala Subrahmanyam Varanasi Date: Mon, 4 Mar 2024 18:46:17 +0530 Subject: [PATCH 4/5] fix(slack-api): send acknowledgement message only if it is a valid slash command --- llmstack/apps/handlers/slack_app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/llmstack/apps/handlers/slack_app.py b/llmstack/apps/handlers/slack_app.py index 7aa8c5710c5..46ebc50c6de 100644 --- a/llmstack/apps/handlers/slack_app.py +++ b/llmstack/apps/handlers/slack_app.py @@ -480,6 +480,10 @@ def run_app(self): template, ) + message = "" + if self._is_valid_slash_command: + message = f"Processing your command - `{self.request.data.get('command')} {self.request.data.get('text')}`" + return { - "message": f"Processing the Command - `{self.request.data.get('command')} {self.request.data.get('text')}`", + "message": message, } From 32e224e91f59317e4601c303aed6a9d4288626df Mon Sep 17 00:00:00 2001 From: Bala Subrahmanyam Varanasi Date: Mon, 4 Mar 2024 19:05:06 +0530 Subject: [PATCH 5/5] fix(slack-api): add response_type and text keys in slash command acknowledgement response to share in channel --- llmstack/apps/apis.py | 7 +++++-- llmstack/apps/handlers/slack_app.py | 8 +++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/llmstack/apps/apis.py b/llmstack/apps/apis.py index d937bb7ac6c..15fbc646abc 100644 --- a/llmstack/apps/apis.py +++ b/llmstack/apps/apis.py @@ -807,9 +807,12 @@ def run(self, request, uid, session_id=None, platform=None): ) response.is_async = True return response - if request.data.get("command"): + if platform == "slack" and request.data.get("command"): return DRFResponse( - result["message"], + data={ + "response_type": "in_channel", + "text": result["message"], + }, status=200, headers={ "Content-Security-Policy": result["csp"] if "csp" in result else "frame-ancestors self", diff --git a/llmstack/apps/handlers/slack_app.py b/llmstack/apps/handlers/slack_app.py index 46ebc50c6de..a32a7bbddcd 100644 --- a/llmstack/apps/handlers/slack_app.py +++ b/llmstack/apps/handlers/slack_app.py @@ -66,9 +66,7 @@ def __init__(self, *args, **kwargs): is_valid_slash_command = False if self._request_slash_command and self._configured_slash_command: - is_valid_slash_command = ( - self._request_slash_command == self._configured_slash_command and self._request_slash_command_text - ) + is_valid_slash_command = self._request_slash_command == self._configured_slash_command self._is_valid_slash_command = is_valid_slash_command @@ -252,8 +250,8 @@ def _is_app_accessible(self): elif self._request_type and not self._is_valid_request_type: error_message = "Invalid Slack request type. Only url_verification and event_callback are allowed." - if self._request_slash_command and not self._request_slash_command_text: - error_message = f"Invalid Slash Command arguments. Command: `{self.request.data.get('command')}`. Arguments: `{self.request.data.get('text') or '-'}`" + # elif self._request_slash_command and not self._request_slash_command_text: + # error_message = f"Invalid Slash Command arguments. Command: `{self.request.data.get('command')}`. Arguments: `{self.request.data.get('text') or '-'}`" elif self._request_type and not self._is_valid_request_type: error_message = f"Invalid Slack event request type - `{self._request_type}`"