Skip to content

feature(slack): slash commands support #176

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions llmstack/apps/apis.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,18 @@ def run(self, request, uid, session_id=None, platform=None):
)
response.is_async = True
return response
if platform == "slack" and request.data.get("command"):
return DRFResponse(
data={
"response_type": "in_channel",
"text": result["message"],
},
Comment on lines +812 to +815

Check warning

Code scanning / CodeQL

Information exposure through an exception

[Stack trace information](1) flows to this location and may be exposed to an external user.
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(
Expand Down
144 changes: 129 additions & 15 deletions llmstack/apps/handlers/slack_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ 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._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

self._is_valid_slash_command = is_valid_slash_command

def app_init(self):
self.slack_config = (
SlackIntegrationConfig().from_dict(
Expand Down Expand Up @@ -120,11 +136,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"],
)
Expand All @@ -148,6 +163,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")

Expand Down Expand Up @@ -187,33 +227,53 @@ 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",
)
is None
or self.request.headers.get("X-Slack-Signature") is None
):
raise Exception("Invalid Slack request")
error_message = "Invalid Slack request"

request_type = self.request.data.get("type")
elif not self._is_valid_app_token:
error_message = "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:
error_message = "Invalid App ID"

elif self._request_slash_command and not self._is_valid_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."

# 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}`"

# 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):
raise Exception("Invalid Slack request")
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)
):
error_message = "Invalid Slack request"

if error_message:
raise Exception(error_message)

# 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")
Expand All @@ -225,7 +285,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()

Expand Down Expand Up @@ -371,3 +431,57 @@ 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

try:
self._is_app_accessible()
except Exception as e:
return {"message": f"{str(e)}"}

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 your command - `{self.request.data.get('command')} {self.request.data.get('text')}`"

return {
"message": message,
}
2 changes: 2 additions & 0 deletions llmstack/apps/integration_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
12 changes: 12 additions & 0 deletions llmstack/apps/types/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down
36 changes: 36 additions & 0 deletions llmstack/client/src/components/apps/AppSlackConfigEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
};
Expand All @@ -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": "",
Expand All @@ -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
Expand All @@ -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;
Expand Down