Details
| ID | SlackV3 |
|---|---|
| Provider | Slack |
| Category | Messaging and Conferencing |
| From Version | 5.5.0 |
| Docker Image | demisto/slackv3:1.0.0.10133006 |
| Supported Modules | Agentix Cortex Cloud Cloud Runtime Security Cloud Posture Security XSIAM EDR |
README
Send messages and notifications to your Slack team.
This integration was integrated and tested with Slack.
Configure SlackV3 in Cortex
Slack V3 utilizes “Socket Mode” to enable the integration to
communicate directly with Slack for mirroring. This requires a dedicated Slack app to be created for the Cortex XSOAR
integration. See Creating a Custom App on how to create your App in Slack.
Refer to
the video tutorial
to learn about configuring SlackV3 using the app manifest.
| Parameter | Description | Required |
|---|---|---|
bot_token |
Slack API bot token. | False |
user_token |
Slack API user token. | False |
app_token |
Slack API app token. | False |
incidentNotificationChannel |
Dedicated Slack channel to receive notifications. | False |
min_severity |
Minimum incident severity by which to send messages to Slack. | False |
incidentType |
Type of incidents created in Slack. | False |
allow_incidents |
Allow external users to create incidents via direct messages. | False |
proxy |
Use system proxy settings. | False |
unsecure |
Trust any certificate (not secure). Make sure to mark this parameter if you want the SlackBlockBuilder script to send a response back to the incident context. | False |
longRunning |
Long running instance. Required for investigation mirroring and direct messages. | False |
bot_name |
Bot display name in Slack (Cortex XSOAR by default). | False |
bot_icon |
Bot icon in Slack - Image URL (Cortex XSOAR icon by default). | False |
Enabled AI assistant |
Enable Cortex Assistant AI integration in Slack. When enabled, users can mention the bot to interact with AI agents for security analysis, threat intelligence, and automated responses. | False |
max_limit_time |
Maximum time to wait for a rate limiting call in seconds. | False |
paginated_count |
Number of objects to return in each paginated call. | False |
filtered_tags |
Comma-separated list of tags by which to filter the messages sent from Cortex XSOAR. Only supported in Cortex XSOAR V6.1 and above. | False |
permitted_notifications |
Types of notifications to send (to individual users and to the dedicated Slack channel, if specified). To enable use User (User Name) -> User Preferences -> Notifications. Only supported in Cortex XSOAR. | False |
common_channels |
For workspaces where a handful of channels are consistently being used, you may add them as a CSV in the format ChannelName:ChannelID. | False |
disable_caching |
When configured, Disable Caching will prevent the integration from paginating to search for Users or Conversations. Additionally, it will prevent excess data from being stored to the integration context. If this parameter is disabled, the instance may create high memory usage. | False |
mirroring |
Enable Incident Mirroring. | False |
enable_outbound_file_mirroring |
Enable Outbound File Mirroring. Whether to enable mirroring from xsoar to slack, mark it file mirroring is required in investigations. | False |
ignore_event_retries |
In some cases, events may not be processed fast enough. If you wish to attempt to retry the event, select false. Note that this can result in some responses being double-posted. Default is True. |
False |
extensive_logging |
Extensive Logging. This parameter will write additional data to the logs and should only be used when you are directed to by XSOAR support. | False |
Caching
When the Disable Caching of Users and Channels parameter is configured, there are no pagination calls made to Slack.
This is to avoid rate limiting which can occur in workspaces where there are excessive amounts of channels or users. If
there were no timeout issues with running commands on your environment prior to pack version 2.3.0, there is no direct
need to enable this mode.
Additionally. with the Common Channels parameter configured, channels and their IDs found in this parameter will be
accessible to the integration to use while caching is disabled.
Finding a Channel ID
Common Channels follows the format, First ChannelName:FirstChannelID, Second ChannelName:SecondChannelID. To find
the channel ID for the channels that are frequently used:
- Navigate to the channel you want to retrieve an ID for.
-
Click the name of the channel.
On the bottom of the presented menu, the channel ID can be found.
Channel IDs typically follow the format C + an alphanumeric string.

Creating a Custom App
- Navigate to: https://api.slack.com/apps/ .
- Click Create an App.

- Click From an app manifest.

- Pick the workspace you would like the app to reside in and click Next.

- Copy the text in the file
found here
. The text is a manifest template with the recommended configuration for your app. - Paste the copied text into the field “YAML” and click Next.

- The next step is a summary of the app we created. Click Create to proceed.

Installing the App to Your Workspace
-
After creating your app, you will be redirected to the Basic Information page of your app settings.
Click Install to Workspace

This will bring up a page which confirms that you are installing the app to your workspace.
If you do not see this step, you must request access from your Slack admin in order to proceed.

- Once the app has been installed you will be redirected to the General page for your app. Scroll down to the
section called App-Level Tokens and click Generate Token and Scopes

- Enter a name for the Token and click Add Scope. Select the
connections:writescope from the list.

- Click Generate.
- You will be redirected to a page which will display your app token. This token begins with
xapp. Copy this token.

- In your Cortex XSOAR SlackV3 instance configuration page, paste the token in the App Token parameter. You may also
configure the App Token as a credential.

- Navigate back to the Slack App configuration page and select OAuth & Permissions. If this screen does not look
like the following image, you may need assistance from your Slack admin.

- Copy the Bot User OAuth Token.
- In your Cortex XSOAR SlackV3 instance configuration page, paste the token in the parameter. You may also configure
the Bot Token as a credential.

NOTE: When utilizing long-running features such as mirroring or SlackAsk, each integration instance must have a dedicated bot
and tokens that should not be used elsewhere. Failure to comply with this requirement may result in issues with incoming
messages and the steady flow of the integration.
Testing the Bot
- Once you have configured the Dedicated Slack channel to receive notifications parameter, open Slack and invite your
new app to the channel you have configured. - Navigate to your Instance Settings page in Cortex XSOAR and click Test. A message should appear in the channel
from your app.
Cortex Agentic Assistant in Slack
The Slack integration allows users to interact with the Cortex Agentic Assistant directly from Slack. Users can mention the bot in any channel or thread to ask security questions, run investigations, and receive AI-powered responses, all without leaving Slack.

Prerequisites
Before you begin, ensure the following requirements are met:
-
Supported Cortex product version — The tenant must be running one of the following versions (or later), depending on your product:
Product Minimum Version Cortex AGENTIX V1.3 Cortex Platform 1.5 Cortex CLOUD 2.1 Cortex XDR 3.18 / 5.1 Cortex XSIAM 2.10 / 3.5 - Slack App installed — The Slack app must be created and installed in your workspace as described in Creating a Custom App and Installing the App to Your Workspace.
- Slack content pack version 3.6.0 or later — This includes the SlackV3 integration.
- User permissions — Each Slack user who interacts with the Cortex Agentic Assistant must have the appropriate permissions to use the Cortex Agentic Assistant in the Cortex platform. Users without the required permissions will receive an error message when attempting to interact with the bot. Contact your Cortex administrator to ensure the relevant roles and permissions are assigned.
How to Enable
To enable this feature, configure the following in the integration instance settings.
- Check Enabled AI assistant.
- Long running instance — The Long running instance checkbox must be enabled in the instance settings (required for listening to bot mentions in real time).
How It Works
Once the prerequisites are met, users can interact with the Cortex Agentic Assistant by mentioning the bot (for example, @YourBotName <your question>) in any Slack channel or thread where the bot has been added.
Note: Only users with the appropriate Cortex Agentic Assistant permissions can interact with the bot. Users without the required permissions will receive an error message prompting them to contact their administrator.
Conversation Flow
- Mention the bot — Type
@YourBotNamefollowed by your question or request in a channel or thread. - Agent selection — On the first interaction, the assistant presents a dropdown menu of available AI agents (e.g., Case Investigation, Threat Intel). Select the agent best suited for your query.
- AI response — The assistant replies in the same thread with the AI-generated response.
- Follow-up questions — Continue the conversation in the same thread by mentioning the bot again. The assistant maintains context from previous messages in the thread (up to the last 5 messages).
Sensitive Action Approval
When the AI agent needs to perform a sensitive action (e.g., blocking an IP, isolating an endpoint), it will:
- Display the action details with an ⚠️ Sensitive action detected warning.
- Present Proceed and Cancel buttons.
- Wait for the conversation owner to approve or reject the action before continuing.
Note: Only the user who started the conversation can approve or reject sensitive actions.
Available Commands
| Command | Description |
|---|---|
@BotName <question> |
Send a question or request to the AI assistant. |
@BotName !help |
Display help information about the assistant, including usage tips. |
@BotName !reset |
Reset the current session and release the thread lock, allowing a new conversation to start. |
Feedback
Each AI response includes feedback buttons:
- Good response 👍 — Mark the response as helpful.
- Bad response 👎 — Opens a feedback modal where you can select specific issues (e.g., Factually incorrect, Unhelpful, Answered another question) and provide additional comments.
Feedback is sent back to the platform to help improve AI responses over time.
Thread Locking
- Each thread is locked to the user who started the conversation. Other users cannot interact with the assistant in the same thread.
- Other users will see a message indicating the thread is locked and can start their own conversation in a different thread.
- To release a locked thread, any Cortex user can type
@BotName !reset.
Session Management
Conversations automatically expire after 14 days of inactivity. After this period, the thread is unlocked, and a new conversation can begin.
Backward Compatibility with Slack V2
Slack V3 contains improvements to enhance the stability of the integration as well as the circumvention of OProxy. This
version is intended to provide customers with more granular control over the Slack integration by enabling the
Bring-Your-Own-App model and customizable scope-based authentication.
All commands are fully compatible with Slack V2 playbooks as their inputs and outputs have remained the same. As a
customer, you should notice no significant change in the behavior of the Slack integration with your existing playbooks.
Commands
You can execute these commands from the Cortex XSOAR CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.
mirror-investigation
Mirrors the investigation between Slack and the Cortex XSOAR War Room.
Base Command
mirror-investigation
Input
| Argument Name | Description | Required |
|---|---|---|
| type | The mirroring type. Can be “all”, which mirrors everything, “chat”, which mirrors only chats (not commands), or “none”, which stops all mirroring. Possible values are: all, chat, none. Default is all. | Optional |
| autoclose | Whether the channel is auto-closed when an investigation is closed. Can be “true” or “false”. Default is “true”. | Optional |
| direction | The mirroring direction. Can be “FromDemisto”, “ToDemisto”, or “Both”. Default value is “Both”. | Optional |
| mirrorTo | The channel type. Can be “channel” or “group”. The default value is “group”. | Optional |
| channelName | The name of the channel. The default is “incident-<incidentID>”. | Optional |
| channelTopic | The topic of the channel. | Optional |
| kickAdmin | Whether to remove the Slack administrator (channel creator) from the mirrored channel. Possible values are: true, false. Default is false. | Optional |
Context Output
There is no context output for this command.
Command Example
!mirror-investigation direction="FromDemisto" channelName="example" using-brand="SlackV3"
Human Readable Output
Investigation mirrored successfully, channel:example
send-notification
Sends a message to a user, group, or channel.
Base Command
send-notification
Input
| Argument Name | Description | Required |
|---|---|---|
| message | The message content. When mentioning another Slack user, make sure to do so in the following format: <@user_name>. | Optional |
| to | The user to whom to send the message. Can be either the username or email address. | Optional |
| channel | The name of the Slack channel to which to send the message. | Optional |
| channel_id | The ID of the Slack channel to which to send the message. | Optional |
| entry | An entry ID to send as a link. | Optional |
| ignoreAddURL | Whether to include a URL to the relevant component in Cortex XSOAR. Can be “true” or “false”. Default value is “false”. | Optional |
| threadID | The ID of the thread to which to reply. Can be retrieved from a previous send-notification command. | Optional |
| blocks | A JSON string of Slack blocks to send in the message. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Slack.Thread.ID | String | The Slack thread ID. |
Command Example
!send-notification channel="example" using-brand="SlackV3"
Context Example
{
"Slack": {
"Thread": {
"ID": "1624272821.000700"
}
}
}
Human Readable Output
Message sent to Slack successfully.
Thread ID is: 1624272821.000700
close-channel
Archives a Slack channel.
Base Command
close-channel
Input
| Argument Name | Description | Required |
|---|---|---|
| channel | The name of the channel to archive. If not provided, the mirrored investigation channel is archived (if the channel exists). | Optional |
| channel_id | The ID of the channel to archive. If not provided, the mirrored investigation channel is archived (if the channel exists). | Optional |
Context Output
There is no context output for this command.
Command Example
!close-channel channel=new-slack-channel
Human Readable Output
Channel successfully archived.
slack-send-file
Sends a file to a user, channel, or group. If not specified, the file is sent to the mirrored investigation channel (if
the channel exists).
Base Command
slack-send-file
Permissions
Permission scopes required for this command:
| Token Type | Scope |
|---|---|
| Granular bot | files:write |
| User | files:write files:write:user |
| Legacy bot | bot |
The full list of Slack API scopes can be accessed here.
Limitations
There are no known limitations for this command.
Input
| Argument Name | Description | Required |
|---|---|---|
| file | The ID of the file entry to send. | Required |
| to | The user to whom to send the file. Can be the username or the email address. | Optional |
| group | The name of the Slack group (private channel) to which to send the file. | Optional |
| channel | The name of the Slack channel to which to send the file. | Optional |
| channel_id | The ID of the Slack channel to which to send the file. | Optional |
| threadID | The ID of the thread to which to reply. Can be retrieved from a previous send-notification command. | Optional |
| comment | A comment to add to the file. | Optional |
Context Output
There is no context output for this command.
Command Example
!slack-send-file file=87@129 channel=testing-docs comment="Look at this gif!"
Human Readable Output
File sent to Slack successfully.
slack-set-channel-topic
Sets the topic for a channel.
Base Command
slack-set-channel-topic
Input
| Argument Name | Description | Required |
|---|---|---|
| channel | The channel name. If not specified, the topic of the mirrored investigation channel is set (if the channel exists). | Optional |
| channel_id | The ID of the channel. If not specified, the topic of the mirrored investigation channel is set (if the channel exists). | Optional |
| topic | The topic for the channel. | Required |
Context Output
There is no context output for this command.
Command Example
!slack-set-channel-topic topic="Testing topic for documentation" channel=testing-docs
Human Readable Output
Topic successfully set.
slack-create-channel
Creates a channel in Slack.
Base Command
slack-create-channel
Input
| Argument Name | Description | Required |
|---|---|---|
| type | The channel type. Can be “private” or “public”. Default is private. | Optional |
| name | The name of the channel. | Required |
| users | A CSV list of user names or email addresses to invite to the channel. For example: “user1, user2…”. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Slack.Channel.ID | String | The ID of the channel. |
| Slack.Channel.Name | String | The name of the channel. |
Context Example
{
"Slack": {
"Channel": {
"ID": "C0R2D2C3PO",
"Name": "random"
}
}
}
Command Example
!slack-create-channel type="private" name="testing-docs"
Human Readable Output
Successfully created the channel testing-docs
slack-invite-to-channel
Invites users to join a channel.
Base Command
slack-invite-to-channel
Input
| Argument Name | Description | Required |
|---|---|---|
| users | A CSV list of usernames or email addresses to invite to join the channel. For example: “user1, user2…”. | Required |
| channel | The name of the channel to which to invite the users. If the name of the channel is not specified, the name of the mirrored investigation channel is used (if the channel exists). | Optional |
| channel_id | The ID of the channel to which to invite the users. If the ID of the channel is not specified, the name of the mirrored investigation channel is used (if the channel exists). | Optional |
Context Output
There is no context output for this command.
Command Example
!slack-invite-to-channel users="Sir Testing McTesterface" channel=new-slack-channel
Human Readable Output
Successfully invited users to the channel.
slack-kick-from-channel
Removes users from the specified channel.
Base Command
slack-kick-from-channel
Input
| Argument Name | Description | Required |
|---|---|---|
| users | A CSV list of usernames or email addresses to remove from the a channel. For example: “user1, user2…”. | Required |
| channel | The name of the channel from which to remove the users. If the name of the channel is not specified, the mirrored investigation channel is used (if the channel exists). | Optional |
| channel_id | The ID of the channel from which to remove the users. If the ID of the channel is not specified, the mirrored investigation channel is used (if the channel exists). | Optional |
Context Output
There is no context output for this command.
Command Example
!slack-kick-from-channel users="Sir Testing McTesterface" channel=new-slack-channel
Human Readable Output
Successfully kicked users from the channel.
slack-rename-channel
Renames a channel in Slack.
Base Command
slack-rename-channel
Input
| Argument Name | Description | Required |
|---|---|---|
| name | The new name of the channel. | Required |
| channel | The current name of the channel. If the name of the channel is not specified, the mirrored investigation channel is used (if the channel exists). | Optional |
| channel_id | The current ID of the channel. If the ID of the channel is not specified, the mirrored investigation channel is used (if the channel exists). | Optional |
Context Output
There is no context output for this command.
Command Example
!slack-rename-channel name="new-slack-channel" channel="testing-docs"
Human Readable Output
Channel renamed successfully.
slack-get-user-details
Get details about a specified user.
Base Command
slack-get-user-details
Input
| Argument Name | Description | Required |
|---|---|---|
| user | The Slack user (username or email). | Required |
Context Output
| Path | Type | Description |
|---|---|---|
| Slack.User.ID | String | The ID of the user. |
| Slack.User.Username | String | The username of the user. |
| Slack.User.Name | String | The actual name of the user. |
| Slack.User.DisplayName | String | The display name of the user. |
| Slack.User.Email | String | The email address of the user. |
| Slack.User.StatusText | String | The status text of the user. |
Command Example
!slack-get-user-details user="cortex_xsoar" using-brand="SlackV3"
Context Example
{
"Slack": {
"User": {
"ID": "U0XXXXXXXX",
"Name": "cortex_xsoar",
"Username": "demisto_integration",
"StatusText": "In a meeting"
}
}
}
Human Readable Output
Details for Slack user: cortex_xsoar
ID Username Name Status Text U0XXXXXXXX demisto_integration cortex_xsoar In a meeting
slack-edit-message
Edit an existing Slack message.
Base Command
slack-edit-message
Input
| Argument Name | Description | Required |
|---|---|---|
| channel | The channel the message is posted in. | Optional |
| channel_id | The ID of the channel the message is posted in. | Optional |
| threadID | The ID of the thread of which to edit. Can be retrieved from a previous send-notification command. | Required |
| message | The updated message. | Optional |
| blocks | A JSON string of the block to send. | Optional |
| ignore_add_url | Whether to include a URL to the relevant component in XSOAR. Can be “true” or “false”. Default value is “false”. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Slack.Thread.ID | String | The timestamp identifier for the message. |
| Slack.Thread.Channel | String | The channel ID the message was posted in. |
| Slack.Thread.Text | String | The text the message was updated with. |
Command Example
!slack-edit-message channel="random" threadID="1629281551.001000" message="Eyy"
Context Example
{
"Slack": {
"Thread": {
"ID": "1629281551.001000",
"Channel": "C0XXXXXXXX",
"Text": "Eyy"
}
}
}
Human Readable Output
The message was successfully edited.
slack-pin-message
Pins a selected message to the given channel.
Base Command
slack-pin-message
Input
| Argument Name | Description | Required |
|---|---|---|
| channel | The channel the message is posted in. | Optional |
| channel_id | The ID of the channel the message is posted in. | Optional |
| threadID | The ID of the thread of which to pin. Can be retrieved from a previous send-notification command. | Required |
Context Output
There is no context output for this command.
Command Example
!slack-pin-message channel=random threadID=1629281551.001000
Human Readable Output
The message was successfully pinned.
slack-get-integration-context
Returns the integration context as a file. Use this command for debug purposes only.
Base Command
slack-get-integration-context
Input
There are no input arguments for this command.
Context Output
There is no context output for this command.
slack-user-session-reset
Reset user session token in Slack.
Base Command
slack-user-session-reset
Input
| Argument Name | Description | Required |
|---|---|---|
| user_id | The user id of the user. | Required |
Context Output
There is no context output for this command.
slack-list-channels
List all of the channels in the organization workspace. This command required scopes depend on the type of channel-like
object you’re working with. To use the command, you’ll need at least one of the channels:, groups:, im: or mpim: scopes
corresponding to the conversation type you’re working with.
Base Command
slack-list-channels
Input
| Argument Name | Description | Required |
|---|---|---|
| name_filter | Supply this argument to only return channels with this name . | Optional |
| channel_types | Default is “public_channel”. You can provide a comma separated list of other channels to include in your results. Possible options are: “public_channel”, “private_channel”, “mpim”, and “im”. Including these options may require changes to your Bot’s OAuth scopes in order to read channels like private, group message, or personal messages. | Optional |
| exclude_archived | Default is true (exclude archived channels). This setting allows the command to read channels that have been archived. | Optional |
| limit | Default is 100. Set this argument to specify how many results to return. If you have more results than the limit you set, you will need to use the cursor argument to paginate your results. | Optional |
| cursor | Default is the first page of results. If you have more results than your limit, you need to paginate your results with this argument. This is found with the next_cursor attribute returned by a previous request’s response_metadata . | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Slack.Channels.ID | string | The ID for the channel |
| Slack.Channels.Name | string | Name of the channel |
| Slack.Channels.Created | number | Epoch timestamp when the channel was created |
| Slack.Channels.Creator | string | ID for the creator of the channel |
| Slack.Channels.Purpose | string | The purpose, or description, of the channel |
slack-get-conversation-history
Fetches a conversation’s history of messages and events
Base Command
slack-get-conversation-history
Input
| Argument Name | Description | Required |
|---|---|---|
| conversation_name | The name of the conversation. For channels, provide the channel name. For direct messages, use the username or display name. Either this or conversation_id is required. If both are provided, conversation_id takes precedence. | Optional |
| conversation_id | The ID of the conversation. Either this or conversation_name is required. If both are provided, conversation_id takes precedence. | Optional |
| limit | Set this argument to specify how many results to return. | Optional |
| from_time | Lower bound for conversation history (sent to Slack ‘oldest’ query parameter). Returns messages with time stamp ≥ this value. Accepts Unix timestamp or ISO strings (e.g., “1727448000.000200”, “2025-10-12T09:00:00+03:00”). Results are returned in descending order. | Optional |
| page_token | Token to retrieve the next page of results. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Slack.Messages.Type | string | The type of the message (e.g., message, file_share, etc.). |
| Slack.Messages.Text | string | The text content of the message. |
| Slack.Messages.UserId | string | The user ID of the message sender. |
| Slack.Messages.Name | string | The username of the message sender. |
| Slack.Messages.FullName | string | The full name of the message sender. |
| Slack.Messages.TimeStamp | string | The timestamp of the message. |
| Slack.Messages.HasReplies | string | Whether the message has replies. |
| Slack.Messages.ThreadTimeStamp | string | The thread timestamp of the message. |
| SlackConversationHistory.NextPageToken | string | Token to retrieve the next page of results. |
Command Example
!slack-get-conversation-history conversation_name="general" limit="2"
Context Example
{
"Slack": {
"Messages": [
{
"Type": "message",
"Text": "Hello team, please review the latest security report.",
"UserId": "U0XXXXXXXX",
"Name": "exampleUser1",
"FullName": "exampleUser2",
"TimeStamp": "1727448123.000100",
"HasReplies": "false",
"ThreadTimeStamp": "1727448123.000100"
},
{
"Type": "message",
"Text": "Incident #1234 has been resolved.",
"UserId": "U0YYYYYYYY",
"Name": "exampleUser2",
"FullName": "exampleUser2",
"TimeStamp": "1727448000.000200",
"HasReplies": "true",
"ThreadTimeStamp": "1727448000.000200"
}
]
},
"SlackConversationHistory": {
"NextPageToken": "exampleToken"
}
}
Human Readable Output
Conversation History for general
Type Text UserId Name FullName TimeStamp HasReplies ThreadTimeStamp message Hello team, please review the latest security report. U0XXXXXXXX exampleUser1 exampleUser1 1727448123.000100 false 1727448123.000100 message Incident #1234 has been resolved. U0YYYYYYYY exampleUser2 exampleUser2 1727448000.000200 true 1727448000.000200
slack-get-conversation-replies
Retrieves replies to specific messages, regardless of whether it’s from a public or private channel, direct message, or
otherwise.
Base Command
slack-get-conversation-replies
Input
| Argument Name | Description | Required |
|---|---|---|
| channel_id | ID of the channel. | Required |
| thread_id | ID of the thread. | Required |
| limit | limit. | Optional |
Context Output
There is no context output for this command.
Known Limitations
- All commands which use
channelas a parameter, it is now advised to usechannel-idusing the channel ID found in
the incident’s context under theSlack.Channels.IDvalue. Usingchannel-idas opposed tochannelwill improve
the performance of the integration. - SlackV3 mirrors incidents by listening to messages being sent in channels the bot has been added to.
Because of this, you may have some users in Slack who are not users in Cortex XSOAR. This will occasionally cause the
module
health to indicate that an error has occurred because a user was unable to be found. In this circumstance, the error
is expected and is purely cosmetic in nature. - In some cases when mirroring an investigation, kicking the admin will cause no further actions to be able to be
performed by the bot. Any subsequent actions taken on the channel (such as mirror out) will result in a “not in
channel” error. - Note: If a dedicated channel is configured, however there are no notifications being sent, verify that the Types of
Notifications to send parameter is populated. - mirror-investigation will only mirror chat messages between Cortex XSOAR and Slack. Images, threads, and files
are not supported at this tme.
Troubleshooting
Issue: The survey sent from SlackBlockBuilder is sent to Slack and submitted successfully, but the response does not
show up in context data in Cortex XSOAR.
Resolution: The most likely cause is that there is no API key entered into the Slack v3 integration instance
settings, or the API key was not created by the default admin user. Ensure that an API key created by a default admin
user is entered into the Slack v3 integration instance settings. Also, make sure to mark the Trust any certificate (
not secure) integration parameter.
Configuration parameters
bot_token—user_token—app_token—incidentNotificationChannel— Dedicated Slack channel to receive notificationsmin_severity— Minimum incident severity by which to send messages to Slackpermitted_notifications— Types of Notifications to Send.incidentType— Type of incidents created in Slackallow_incidents— Allow external users to create incidents via DMignore_event_retries— Ignore event retriesproxy— Use system proxy settingsunsecure— Trust any certificate (not secure)mirroring— Enable Incident Mirroringenable_outbound_file_mirroring— Enable Outbound File MirroringlongRunning— Long running instance. Required for investigation mirroring and direct messages.bot_name— Bot display name in Slackbot_icon— Bot icon in Slack - Image URL (Cortex XSOAR icon by default)max_limit_time— Maximum time to wait for a rate limiting call in secondsenabled_ai_assistant— Enabled AI assistantpaginated_count— Number of objects to return in each paginated callfiltered_tags— Comma-separated list of tags to filter the messages sent from Cortex XSOAR.demisto_api_key—enable_dm— Enable DMs to the botdisable_caching— Disable Caching of Users and Channelsextensive_logging— Extensive Loggingcommon_channels— Common Channels
Commands (18)
-
close-channelArchives a Slack channel.
-
mirror-investigationMirrors the investigation between Slack and the Cortex XSOAR War Room.
-
send-notificationSends a message to a user, group, or channel.
-
slack-create-channelCreates a channel in Slack.
-
slack-edit-messageEdit an existing Slack message.
-
slack-get-conversation-historyFetches a conversation's history of messages and events.
-
slack-get-conversation-repliesRetrieves replies to specific messages, regardless of whether it's from a public or private channel, direct message, or otherwise.
-
slack-get-integration-contextReturns the integration context as a file. Use this command for debug purposes only.
-
slack-get-user-detailsGet details about a specified user.
-
slack-invite-to-channelInvites users to join a channel.
-
slack-kick-from-channelRemoves users from the specified channel.
-
slack-list-channelsList all of the channels in the organization workspace. This command required scopes depend on the type of channel-like object you're working with. To use the command, you'll need at least one of the channels:, groups:, im: or mpim: scopes corresponding to the conversation type you're working with.
-
slack-pin-messagePins a selected message to the given channel.
-
slack-rename-channelRenames a channel in Slack.
-
slack-send-fileSends a file to a user, channel, or group. If not specified, the file is sent to the mirrored investigation channel (if the channel exists).
-
slack-send-notification-quick-actionSends a message to a user, group, or channel.
-
slack-set-channel-topicSets the topic for a channel.
-
slack-user-session-resetReset user session token in Slack.
import asyncio import concurrent import logging.handlers import ssl import re import json import threading from typing import Literal, TypedDict, get_args from urllib.parse import urlparse from datetime import UTC, datetime import dateparser import aiohttp import demistomock as demisto # noqa: F401 import slack_sdk from CommonServerPython import * # noqa: F401 from slack_sdk.errors import SlackApiError from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.web.slack_response import SlackResponse from SlackUtilsApiModule import * """ CONSTANTS """ ALLOWED_HTTP_VERBS = Literal["POST", "GET"] SEVERITY_DICT = {"Unknown": 0, "Low": 1, "Medium": 2, "High": 3, "Critical": 4} USER_TAG_EXPRESSION = "<@(.*?)>" CHANNEL_TAG_EXPRESSION = "<#(.*?)>" # Matches an already-resolved Slack user/bot ID (e.g. U012A3CDE, W01AB2CD3, B0123ABCD). # Slack IDs are uppercase, start with U/W/B and are followed by uppercase letters/digits. # Real IDs are currently ~9-11 chars; the generous upper bound (8-15 total) leaves room # for future ID-length growth while keeping the match tight enough to avoid treating # human display names as IDs. SLACK_USER_ID_EXPRESSION = re.compile(r"^[UWB][A-Z0-9]{7,14}$") URL_EXPRESSION = r"<(https?://.+?)(?:\|.+)?>" GUID_REGEX = r"(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}" # The incident/investigation id (the part after '@') can be a GUID, a plain numeric id # (classic XSOAR), or an 'INCIDENT-<n>' id in a Cortex Platform Case War Room. The optional # 'INCIDENT-' prefix covers the latter so SlackBlockBuilder blocks are unwrapped and SlackAsk # replies are routed correctly from Case War Rooms (XSUP-73616). ENTITLEMENT_REGEX = rf"{GUID_REGEX}@(({GUID_REGEX})|(?:INCIDENT-)?(?:[\d_]+))_*(\|\S+)?\b" COMMAND_REGEX = r"command.*?(?=;)" MESSAGE_FOOTER = "\n**From Slack**" MIRROR_TYPE = "mirrorEntry" INCIDENT_OPENED = "incidentOpened" INCIDENT_NOTIFICATION_CHANNEL = "incidentNotificationChannel" PLAYGROUND_INVESTIGATION_TYPE = 9 WARNING_ENTRY_TYPE = 11 POLL_INTERVAL_MINUTES: Dict[tuple, float] = {(0, 15): 1, (15, 60): 2, (60,): 5} DATE_FORMAT = "%Y-%m-%d %H:%M:%S" OBJECTS_TO_KEYS = {"mirrors": "investigation_id", "questions": "entitlement", "users": "id"} SYNC_CONTEXT = True PROFILING_DUMP_ROWS_LIMIT = 20 MAX_SAMPLES = 10 class FileUploadParams(TypedDict): filename: str file: str # file path initial_comment: Optional[str] channel: str # channel ID thread_ts: Optional[str] # thread ID """ GLOBALS """ USER_TOKEN: str BOT_TOKEN: str APP_TOKEN: str PROXY_URL: Optional[str] PROXIES: dict DEDICATED_CHANNEL: str ASYNC_CLIENT: AsyncWebClient CLIENT: slack_sdk.WebClient USER_CLIENT: slack_sdk.WebClient ALLOW_INCIDENTS: bool INCIDENT_TYPE: str SEVERITY_THRESHOLD: int VERIFY_CERT: bool SSL_CONTEXT: Optional[ssl.SSLContext] QUESTION_LIFETIME: int BOT_NAME: str BOT_ICON_URL: str MAX_LIMIT_TIME: int PAGINATED_COUNT: int ENABLE_DM: bool DEFAULT_PERMITTED_NOTIFICATION_TYPES: List[str] CUSTOM_PERMITTED_NOTIFICATION_TYPES: List[str] PERMITTED_NOTIFICATION_TYPES: List[str] COMMON_CHANNELS: dict DISABLE_CACHING: bool CHANNEL_NOT_FOUND_ERROR_MSG: str BOT_ID: str CACHED_INTEGRATION_CONTEXT: dict CACHE_EXPIRY: float MIRRORING_ENABLED: bool FILE_MIRRORING_ENABLED: bool LONG_RUNNING_ENABLED: bool DEMISTO_API_KEY: str DEMISTO_URL: str IGNORE_RETRIES: bool EXTENSIVE_LOGGING: bool ENABLED_AI_ASSISTANT: bool """ HELPER FUNCTIONS """ # ============================================================================ # Slack Assistant Handler - Implementation of AssistantMessagingHandler for Slack # ============================================================================ class SlackAssistantHandler(AssistantMessagingHandler): """ Slack implementation of AssistantMessagingHandler. Handles all Assistant interactions specific to Slack platform. """ PLATFORM_NAME = "Slack" SUPPORTS_MESSAGE_HISTORY = True def __init__(self): """Initialize the Slack Assistant handler""" super().__init__() # ============================================================================ # Implementation of abstract methods from AssistantMessagingHandler # ============================================================================ async def send_message_async( self, channel_id: str, message: str, thread_id: str = "", blocks: list | None = None, attachments: list | None = None, ephemeral: bool = False, user_id: str = "", ): """ Send a message to Slack. Args: channel_id: The Slack channel ID message: The message text thread_id: Optional thread timestamp blocks: Optional Slack blocks attachments: Optional attachments ephemeral: Whether message should be ephemeral user_id: User ID for ephemeral messages """ return send_message_to_destinations( [channel_id], message, thread_id, blocks=blocks or [], attachments=attachments, ephemeral_message=ephemeral, user_id=user_id, bot_name=AssistantMessages.BOT_DISPLAY_NAME, ) async def update_message( self, channel_id: str, message_ts: str, text: str = "", blocks: list | None = None, ): """ Update an existing Slack message. Args: channel_id: The Slack channel ID message_ts: The message timestamp text: Optional new text blocks: Optional new blocks """ self._validate_update_message_args(text, blocks) body: Dict = {"channel": channel_id, "ts": message_ts} if text: body["text"] = text if blocks: body["blocks"] = blocks return await send_slack_request_async(client=ASYNC_CLIENT, method="chat.update", body=body) def delete_message( self, channel_id: str, message_ts: str, ) -> tuple[bool, dict]: """ Delete an existing Slack message. Implements the abstract method from AssistantMessagingHandler. Args: channel_id: The Slack channel ID message_ts: The message timestamp Returns: A tuple of (success, response). """ body: dict = {"channel": channel_id, "ts": message_ts} response = send_slack_request_sync(CLIENT, "chat.delete", body=body) return response.get("ok", False), dict(response) async def get_user_info(self, user_id: str) -> dict: """ Get Slack user information. Args: user_id: The Slack user ID Returns: User information dictionary """ return await get_user_details(user_id) # type: ignore[return-value] async def get_thread_last_messages(self, channel_id: str, thread_ts: str, limit: int = 20) -> list: """ Get Slack conversation history. Args: channel_id: The channel ID thread_ts: The thread timestamp limit: Maximum number of messages Returns: List of messages """ response = await send_slack_request_async( ASYNC_CLIENT, "conversations.replies", http_verb="GET", body={"channel": channel_id, "ts": thread_ts, "limit": limit} ) return response.get("messages", []) def format_user_mention(self, user_id: str) -> str: """ Format a Slack user mention. Args: user_id: The Slack user ID Returns: Formatted user mention string (e.g., "<@U12345>") """ return f"<@{user_id}>" def normalize_message_from_user(self, text: str) -> str: """ Normalize Slack message for backend by removing markdown formatting. Implements the abstract method from AssistantMessagingHandler. Args: text: The Slack message text with markdown formatting Returns: Normalized text suitable for backend """ return normalize_slack_message_from_user(text) def prepare_message_blocks(self, message: str, message_type: str) -> tuple: """ Prepare Slack-specific message blocks. Args: message: The message text message_type: The message type Returns: Tuple of (blocks, attachments) """ return format_md_to_slack_message(message, message_type) def prepare_merged_step_blocks(self, step_contents: list[str]) -> tuple[list, list]: """ Prepare Slack blocks for multiple merged step messages. Uses DividerBlock between steps for visual separation. Args: step_contents: List of step message content strings to merge Returns: Tuple of (blocks, attachments) for the merged step message """ return prepare_slack_merged_step_messages(step_contents) def create_agent_selection_ui(self, agents: list) -> list: """ Create Slack agent selection UI. Args: agents: List of available agents Returns: Slack blocks for agent selection """ return create_agent_selection_blocks(agents) def create_approval_ui(self) -> list: """ Create Slack approval UI for sensitive actions. Returns: Slack blocks for approval """ return get_approval_buttons_block() def create_script_notice_ui(self) -> dict | None: """ Create Slack script availability notice UI using a rich_text quote block for a visually framed appearance. Returns: Slack rich_text block with a quoted notice, or None. """ return { "type": "rich_text", "elements": [ { "type": "rich_text_quote", "elements": [ {"type": "emoji", "name": "clipboard"}, { "type": "text", "text": f" {AssistantMessages.SCRIPT_AVAILABLE_NOTICE}", "style": {"bold": True}, }, ], } ], } def create_feedback_ui(self, message_id: str) -> dict: """ Create Slack feedback UI. Args: message_id: The message ID for tracking Returns: Slack feedback buttons block """ return get_feedback_buttons_block(message_id) def post_agent_response( self, channel_id: str, thread_id: str, blocks: list, attachments: list, agent_name: str = "", fallback_text: str = "" ) -> dict: """ Send a new message to Slack. Implements the abstract method from AssistantMessagingHandler. Args: channel_id: The channel ID thread_id: The thread ID blocks: Message blocks attachments: Message attachments agent_name: Optional agent name for bot display fallback_text: Plain text to use if blocks/attachments fail Returns: Response dict with 'ts' if successful """ try: response = send_message_to_destinations( [channel_id], "", thread_id, blocks, attachments, bot_name=AssistantMessages.BOT_DISPLAY_NAME ) if response: return {"ts": response.get("ts")} return {} except SlackApiError as e: # If blocks or attachments are invalid, send as plain text so the message isn't lost. error_str = str(e) if "invalid_blocks" in error_str or "invalid_attachments" in error_str: demisto.error(f"Invalid blocks/attachments format, sending as plain text: {e}") if fallback_text: response = send_message_to_destinations( [channel_id], fallback_text, thread_id, bot_name=AssistantMessages.BOT_DISPLAY_NAME ) if response: return {"ts": response.get("ts")} raise def update_context(self, context_updates: dict): """ Update the integration context. Implements the abstract method from AssistantMessagingHandler. Args: context_updates: Dictionary of updates to apply """ set_to_integration_context_with_retries(context_updates, OBJECTS_TO_KEYS, SYNC_CONTEXT) async def show_feedback_modal( self, trigger_id: str, message_id: str, channel_id: str, thread_ts: str, ): """ Open a feedback modal for negative feedback collection. Implements the abstract method from AssistantMessagingHandler. Delegates to get_feedback_modal() for modal construction. Args: trigger_id: The trigger ID for opening the modal message_id: The message ID for tracking channel_id: The channel ID thread_ts: The thread timestamp """ # Get modal view from SlackUtilsApiModule (uses SDK classes) modal_view = get_feedback_modal(message_id, channel_id, thread_ts) # Use send_slack_request_async for retry logic await send_slack_request_async( client=ASYNC_CLIENT, method="views.open", body={"trigger_id": trigger_id, "view": modal_view.to_dict()} ) async def get_conversation_context_formatted( self, channel_id: str, thread_ts: str, bot_id: str, current_message_ts: str, max_context_messages: int = AssistantMessagingHandler.MAX_CONTEXT_MESSAGES, ) -> str: """ Retrieves and formats Slack conversation context. Slack-specific implementation. Args: channel_id: The Slack channel ID thread_ts: The thread timestamp bot_id: The bot user ID current_message_ts: The current message timestamp max_context_messages: Maximum number of previous messages to include as context Returns: Formatted context string """ try: # Get conversation history messages = await self.get_thread_last_messages(channel_id, thread_ts) if not messages: demisto.debug(f"No conversation history found for thread {thread_ts}") return "" # Filter and collect context messages context_messages = [] bot_mention = f"<@{bot_id}>" for msg in reversed(messages): # Process from oldest to newest msg_ts = msg.get("ts", "") msg_text = msg.get("text", "") msg_user = msg.get("user", "") msg_bot_id = msg.get("bot_id", "") # Skip current message if msg_ts == current_message_ts: continue # Skip bot messages if msg_bot_id or msg_user == bot_id: continue # Stop if we hit a previous bot mention if bot_mention in msg_text and msg_ts != current_message_ts: break # Add to context if msg_text and msg_user: # Get user name try: user_response = await send_slack_request_async( ASYNC_CLIENT, "users.info", http_verb="GET", body={"user": msg_user} ) user_info: dict[str, Any] = user_response.get("user", {}) user_name = user_info.get("real_name", user_info.get("name", msg_user)) except Exception: user_name = msg_user context_messages.append({"user": user_name, "text": msg_text, "ts": msg_ts}) # Limit context messages if len(context_messages) >= max_context_messages: break # Use base class formatting context_str = self.format_context_messages(context_messages) if context_str: demisto.debug(f"Retrieved {len(context_messages)} context messages for thread {thread_ts}") return context_str except Exception as e: demisto.error(f"Failed to get Slack conversation context: {e}") return "" # Create a global instance of the handler slack_assistant_handler = SlackAssistantHandler() def send_agent_response(): """ Wrapper function for the send-agent-response command. Delegates to SlackAssistantHandler.send_agent_response() Expects 'messages' arg as a list of message dicts, each containing: - content (str): The message text - response_type (str): The message type (from AssistantMessageType) - is_final (bool): Whether this is the final message - message_id (str): Optional message ID for feedback tracking - metadata (dict): Optional metadata """ args = demisto.args() # Format agent name if provided agent_name = args.get("agent_name", "") bot_name = AssistantMessages.AGENT_BOT_NAME_FORMAT.format(agent_name) if agent_name else "" # Get current integration context integration_context = get_integration_context(SYNC_CONTEXT) assistant_context = integration_context.get(slack_assistant_handler.CONTEXT_KEY, {}) if isinstance(assistant_context, str): assistant_context = json.loads(assistant_context) channel_id = args["channel_id"] thread_id = str(args["thread_id"]) assistant_id_key = f"{channel_id}_{thread_id}" # Get user_id from assistant context if available user_id = assistant_context.get(assistant_id_key, {}).get("user", "") # Parse messages list from args raw_messages = args.get("messages", []) if isinstance(raw_messages, str): raw_messages = json.loads(raw_messages) messages_metadata = [ { "response_type": message.get("response_type"), "is_final": message.get("is_final"), "message_id": message.get("message_id"), "content_length": len(message.get("content") or ""), } for message in raw_messages if isinstance(message, dict) ] demisto.debug( "send_agent_response called with " f"channel_id={channel_id}, thread_id={thread_id}, agent_name={agent_name}, " f"user_id={user_id}, messages_count={len(raw_messages)}, " f"messages_metadata={messages_metadata}" ) # Call the handler's send_agent_response method slack_assistant_handler.send_agent_response( channel_id=channel_id, thread_id=thread_id, messages=raw_messages, assistant_context=assistant_context, assistant_id_key=assistant_id_key, agent_name=bot_name, user_id=user_id, ) def get_war_room_url(url: str) -> str: """ Constructs a war room URL based on the input URL and incident context. Workarounds for known bugs: - CRTX-107526 for XSIAM URLs - CRTX-183586 for platform URLs """ incident_id = demisto.callingContext.get("context", {}).get("Inv", {}).get("id") parsed_url = urlparse(url) base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" # a workaround until this bug is resolved: https://jira-dc.paloaltonetworks.com/browse/CRTX-107526 if is_xsiam(): if incident_id and incident_id.startswith("INCIDENT-"): # Executed from the incident War Room case_id = incident_id.split("-")[-1] return f"{base_url}/incidents/war_room?caseId={case_id}" # Executed from the alert War Room return f"{base_url}/incidents/alerts_and_insights?caseId={incident_id}&action:openAlertDetails={incident_id}-warRoom" # a workaround until this bug is resolved: https://jira-dc.paloaltonetworks.com/browse/CRTX-183586 if is_platform(): if incident_id and incident_id.startswith("INCIDENT-"): # Executed from the cases War Room case_id = incident_id.split("-")[-1] return f"{base_url}/cases/war_room?caseId={case_id}" # Executed from the issue War Room return f"{base_url}/issue-view/{incident_id}" return url def get_bot_id() -> str: """ Gets the app bot ID Returns: The app bot ID """ response = CLIENT.auth_test() return response.get("user_id") # type: ignore def test_module(): """ Sends a test message to the dedicated slack channel. """ if not DEDICATED_CHANNEL and len(CUSTOM_PERMITTED_NOTIFICATION_TYPES) > 0: return_error("When 'Types of Notifications to Send' is populated, a dedicated channel is required.") if not BOT_TOKEN.startswith("xoxb"): return_error("Invalid Bot Token.") if not APP_TOKEN.startswith("xapp"): return_error("Invalid App Token.") if USER_TOKEN and not USER_TOKEN.startswith("xoxp"): return_error("Invalid User Token.") elif not DEDICATED_CHANNEL and len(CUSTOM_PERMITTED_NOTIFICATION_TYPES) == 0: CLIENT.auth_test() # type: ignore if USER_TOKEN: USER_CLIENT.auth_test() else: channel = get_conversation_by_name(DEDICATED_CHANNEL) if not channel: return_error(CHANNEL_NOT_FOUND_ERROR_MSG) message = "Hi there! This is a test message." CLIENT.chat_postMessage(channel=channel.get("id"), text=message) # type: ignore # Status of mirroring check if MIRRORING_ENABLED and not LONG_RUNNING_ENABLED: demisto.error( "Mirroring is enabled, however long running is disabled. For mirrors to work correctly," " long running must be enabled." ) # validation for permitted_notifications since not all the options are supported by xsiam if is_xsiam() or is_platform(): xsiam_permitted_notification_types = { "investigationClosed", "investigationDeleted", "incidentReminderSLA", "taskCompleted", "failedFetchIncidents", "mentionNew", "mentionOld", } if not_allowed := set(CUSTOM_PERMITTED_NOTIFICATION_TYPES).difference(xsiam_permitted_notification_types): demisto.error(f"The {','.join(sorted(not_allowed))} 'Types of Notifications to Send' are not supported in XSIAM.") demisto.results("ok") def get_current_utc_time() -> datetime: """ Returns: The current UTC time. """ return datetime.utcnow() def next_expiry_time() -> float: """ Returns: A float representation of a new expiry time with an offset of 5 seconds """ now = datetime.now(UTC) unix_timestamp = now.timestamp() unix_timestamp_plus_5_seconds = unix_timestamp + 5 return unix_timestamp_plus_5_seconds def format_user_not_found_error(user: str) -> str: err_str = f"User {user} not found in Slack" if DISABLE_CACHING: err_str += ( " and Disable Caching of Users and Channels is enabled. While caching is checked, it is advised to" " perform actions using a users email. If this command worked previously for you, you may try" " disabling the Disable Caching parameter from the instance configuration, however, this is not" " recommended. Please refer to https://xsoar.pan.dev/docs/reference/integrations/slack-v3#caching" " for more details." ) return err_str def return_user_filter(user_to_search: str, users_list): """ Looks through the inputted list and if the user to search for exists, will return the user. Otherwise, it will return an empty dict Args: user_to_search: The user we are searching for users_list: A list of user dictionaries to search through Returns: The dict which matched the user to search for if one was found, else an empty dict. """ users_filter = list( filter( lambda u: u.get("name", "").lower() == user_to_search or u.get("id", "").lower() == user_to_search or u.get("profile", {}).get("display_name", "").lower() == user_to_search or u.get("profile", {}).get("email", "").lower() == user_to_search or u.get("profile", {}).get("real_name", "").lower() == user_to_search, users_list, ) ) if users_filter: return users_filter[0] else: return {} def get_user_by_email(user_to_search: str) -> dict: """ Searches for a user when given the user's email. Args: user_to_search: the user's email we are searching for. Returns: Formatted user results if a user is found. """ if not re.match(emailRegex, user_to_search): raise ValueError("must provide user email") _body = {"email": user_to_search} response = send_slack_request_sync(CLIENT, "users.lookupByEmail", http_verb="GET", body=_body) user = response.get("user", {}) # type: ignore if not user: err_str = format_user_not_found_error(user_to_search) demisto.results({"Type": WARNING_ENTRY_TYPE, "Contents": err_str, "ContentsFormat": formats["text"]}) return {} else: return format_user_results(user) def add_user_to_context(user: dict, integration_context=None): """ In some cases, we want to save user data to the integration context. Args: integration_context: The current context for the instance of the Slack which executed the command. user: The context safe version of a user's data to insert into the context. """ if integration_context.get("users"): users = json.loads(integration_context["users"]) users.append(user) else: users = [user] set_to_integration_context_with_retries({"users": users}, OBJECTS_TO_KEYS, SYNC_CONTEXT) def paginated_search_for_user(user_to_search: str): """ When a user cannot be found via the context or users.lookupByEmail, ONLY if DISABLE_CACHING is false, will we attempt to paginate through the Slack users.list results. Please note, for large workspaces, this action can exceed the timeout limit for the command which triggered it. Args: user_to_search: String representation of the user we are looking for. Can be user_name, display_name, real_name Returns: If a user is found, will return a context safe version of the user found. Else, will return an empty dict. """ demisto.debug(f"Attempting to fetch data for {user_to_search} from Slack API.") body = {"limit": PAGINATED_COUNT} response = send_slack_request_sync(CLIENT, "users.list", http_verb="GET", body=body) while True: workspace_users = response["members"] if response and response.get("members", []) else [] cursor = response.get("response_metadata", {}).get("next_cursor") # type: ignore[call-overload] user = return_user_filter(user_to_search.lower(), workspace_users) # type: ignore[call-overload] if user: break if not cursor: break body = body.copy() # strictly for unit testing purposes body.update({"cursor": cursor}) response = send_slack_request_sync(CLIENT, "users.list", http_verb="GET", body=body) if user: demisto.debug(f"User {user_to_search} was found.") return format_user_results(user) else: demisto.info(format_user_not_found_error(user=user_to_search)) return {} def format_user_results(user: dict): """ Formats and truncates the user result from the Slack API Args: user: The user response retrieved from the Slack API Returns: Formatted and truncated version of the result which is context safe. """ return { "name": user.get("name"), "id": user.get("id"), "profile": { "email": user.get("profile", {}).get("email", ""), "real_name": user.get("profile", {}).get("real_name", ""), "display_name": user.get("profile", {}).get("display_name", ""), "status_text": user.get("profile", {}).get("status_text", ""), }, } def get_user_by_name_without_caching(user_to_search: str) -> dict: """ When Disable Caching is true, we look for the user by email only. Args: user_to_search: The user's email Returns: A slack user object """ user = {} if re.match(emailRegex, user_to_search): demisto.debug(f"Checking via API for email of {user_to_search}") user = get_user_by_email(user_to_search) return user def get_user_by_name_with_caching(user_to_search, add_to_context): """ When Disable Caching is false, we look for the user first in the context. If the user is not found, then we proceed to search for the user by email, if that fails, then we will search for the user using a paginated call. Args: user_to_search: The user's email Returns: A slack user object """ user = {} integration_context = get_integration_context(SYNC_CONTEXT) if integration_context.get("users"): demisto.debug(f"Checking in context for {user_to_search}") # Check if we already have the user to prevent call to users.lookupByEmail users = json.loads(integration_context["users"]) user = return_user_filter(user_to_search.lower(), users) if not user and re.match(emailRegex, user_to_search): demisto.debug(f"Checking via API for email of {user_to_search}") user = get_user_by_email(user_to_search) if user and add_to_context: integration_context = get_integration_context(SYNC_CONTEXT) add_user_to_context(user=user, integration_context=integration_context) if not user: demisto.debug(f"Couldn't find {user_to_search} and caching is disabled. Checking API") user = paginated_search_for_user(user_to_search) demisto.debug(f"Found {user_to_search} - {user}") if user and add_to_context: integration_context = get_integration_context(SYNC_CONTEXT) add_user_to_context(user=user, integration_context=integration_context) return user def get_user_by_name(user_to_search: str, add_to_context: bool = True) -> dict: """ Gets a slack user by a user name Args: user_to_search: The user name or email add_to_context: Whether to update the integration context Returns: A slack user object """ if DISABLE_CACHING: return get_user_by_name_without_caching(user_to_search=user_to_search) else: return get_user_by_name_with_caching(user_to_search=user_to_search, add_to_context=add_to_context) def get_user_by_id(user_id: str) -> dict: """ Retrieves a Slack user's details by user ID. Args: user_id: The user's unique ID in Slack. Returns: Formatted user results if a user is found. """ if not user_id: raise ValueError("User ID must be provided") _body = {"user": user_id} response = send_slack_request_sync(CLIENT, "users.info", http_verb="GET", body=_body) user = response.get("user", {}) # type: ignore if not user: err_str = format_user_not_found_error(user_id) demisto.results({"Type": WARNING_ENTRY_TYPE, "Contents": err_str, "ContentsFormat": formats["text"]}) return {} else: return format_user_results(user) def search_slack_users(users: Union[list, str]) -> list: """ Search given users in Slack Args: users: The users to find Returns: The slack users """ slack_users = [] if not isinstance(users, list): users = [users] # Filter NoneType elements from the user list users = list(filter(lambda x: x, users)) for user in users: slack_user = get_user_by_name(user) if not slack_user: err_str = format_user_not_found_error(user=user) demisto.results({"Type": WARNING_ENTRY_TYPE, "Contents": err_str, "ContentsFormat": formats["text"]}) else: slack_users.append(slack_user) return slack_users def find_mirror_by_investigation() -> dict: """ Finds a mirrored channel by the mirrored investigation Returns: The mirror object """ mirror: dict = {} investigation = demisto.investigation() if investigation: integration_context = get_integration_context(SYNC_CONTEXT) if integration_context.get("mirrors"): mirrors = json.loads(integration_context["mirrors"]) investigation_filter = list(filter(lambda m: investigation.get("id") == m["investigation_id"], mirrors)) if investigation_filter: mirror = investigation_filter[0] return mirror def set_name_and_icon(body: dict, method: str, bot_name: str = ""): """ If provided, sets a name and an icon for the bot if a message is sent. Args: body: The message body. method: The current API method. bot_name: Optional name for the bot. """ if method in ["chat.postMessage", "chat.postEphemeral"]: if bot_name or BOT_NAME: body["username"] = bot_name or BOT_NAME if BOT_ICON_URL: body["icon_url"] = BOT_ICON_URL def validate_slack_request_args( http_verb: str, method: Optional[str], file_upload_params: Optional[FileUploadParams], ) -> None: """ Performs basic pre-validation on the Slack request arguments Args: http_verb: The HTTP method to use. method: The Slack web API method to use (not relevant when file_upload_params are specified). file_upload_params: An instance of FileUploadParams (for uploading using the file-upload APIs). Raises: ValueError: If neither method nor file_upload_params are specified, or if an invalid http_verb is used. """ if not method and not file_upload_params: # empty method is only allowed when uploading a file raise ValueError("Either a Slack web API method or file_upload_params need to specified") allowed_http_verb_values: tuple = get_args(ALLOWED_HTTP_VERBS) if http_verb not in allowed_http_verb_values: raise ValueError(f'Invalid http_verb: {http_verb}. Allowed values: {", ".join(allowed_http_verb_values)}.') def send_slack_request_sync( client: slack_sdk.WebClient, method: str = "", # irrelevant when file_upload_params are specified http_verb: ALLOWED_HTTP_VERBS = "POST", body: Optional[dict] = None, file_upload_params: Optional[FileUploadParams] = None, bot_name: str = "", ) -> SlackResponse: """ Sends a request to slack API while handling rate limit errors. Args: client: The Synchronous Slack client. method: The Slack web API method to use (irrelevant when file_upload_params are specified). http_verb: The HTTP method to use. body: The request body. file_upload_params: An instance of FileUploadParams (for uploading using the file-upload APIs). bot_name: Optional name for the bot. Returns: The Slack API response. """ validate_slack_request_args(http_verb=http_verb, method=method, file_upload_params=file_upload_params) if body is None: body = {} set_name_and_icon(body, method, bot_name) total_try_time = 0 while True: try: demisto.debug(f"Sending slack {method} (sync). Body is: {body!s}") if file_upload_params: # When file_upload_params provided, use three-stage `file_upload_v2` wrapper method in Slack's SDK client # https://tools.slack.dev/python-slack-sdk/web/#uploading-files response = client.files_upload_v2(**file_upload_params) elif http_verb == "POST": response = client.api_call(method, json=body) else: response = client.api_call(method, http_verb="GET", params=body) except SlackApiError as api_error: response = api_error.response headers = response.headers # type: ignore if "Retry-After" in headers: demisto.info(f"Got rate limit error (sync). Body is: {body!s}\n{api_error}") retry_after = int(headers["Retry-After"]) total_try_time += retry_after if total_try_time < MAX_LIMIT_TIME: try: safe_sleep(retry_after) except ValueError: raise DemistoException( f"Got rate limit error (sync) and reached docker timeout. Body is: {body!s}\n{api_error}" ) continue raise break return response # type: ignore async def send_slack_request_async( client: AsyncWebClient, method: str = "", # irrelevant when file_upload_params are specified http_verb: ALLOWED_HTTP_VERBS = "POST", body: Optional[dict] = None, file_upload_params: Optional[FileUploadParams] = None, ) -> SlackResponse: """ Sends an async request to slack API while handling rate limit errors. Args: client: The Asynchronous Slack client. method: The Slack web API method to use (irrelevant when file_upload_params are specified). http_verb: The HTTP method to use. body: The request body. file_upload_params: An instance of FileUploadParams (for uploading using the file upload APIs). Returns: The Slack API response. """ validate_slack_request_args(http_verb=http_verb, method=method, file_upload_params=file_upload_params) if body is None: body = {} set_name_and_icon(body, method) total_try_time = 0 while True: try: demisto.debug(f"Sending slack {method} (async). Body is: {body!s}") if file_upload_params: # When file_upload_params provided, use three-stage `file_upload_v2` wrapper method in Slack's SDK client # https://tools.slack.dev/python-slack-sdk/web/#uploading-files response = await client.files_upload_v2(**file_upload_params) elif http_verb == "POST": response = await client.api_call(method, json=body) else: response = await client.api_call(method, http_verb="GET", params=body) except SlackApiError as api_error: demisto.debug(f"Got rate limit error (async). Body is: {body!s}\n{api_error}") response = api_error.response headers = response.headers if "Retry-After" in headers: retry_after = int(headers["Retry-After"]) total_try_time += retry_after if total_try_time < MAX_LIMIT_TIME: await asyncio.sleep(retry_after) continue raise break return response # type: ignore """ MIRRORING """ async def get_slack_name(slack_id: str, client: AsyncWebClient) -> str: """ Get the slack name of a provided user or channel by its ID Args: client: The slack client slack_id: The slack user or channel ID Returns: The slack user or channel name """ if not slack_id: return "" integration_context = get_integration_context(SYNC_CONTEXT) prefix = slack_id[0] slack_name = "" if prefix in ["C", "D", "G"]: slack_id = slack_id.split("|")[0] conversation: dict = {} if integration_context.get("conversations"): conversations = list(filter(lambda c: c["id"] == slack_id, json.loads(integration_context["conversations"]))) if conversations: conversation = conversations[0] if not conversation: conversation = await client.conversations_info(channel=slack_id) # type: ignore slack_name = conversation.get("name", "") elif prefix == "U": user: dict = {} if integration_context.get("users"): users = list(filter(lambda u: u["id"] == slack_id, json.loads(integration_context["users"]))) if users: user = users[0] if not user: user = await client.users_info(user=slack_id) # type: ignore slack_name = user.get("name", "") return slack_name async def clean_message(message: str, client: AsyncWebClient) -> str: """ Prettifies a slack message - replaces tags and URLs with clean expressions Args: message: The slack message client: The slack client Returns: The clean slack message """ matches = re.findall(USER_TAG_EXPRESSION, message) matches += re.findall(CHANNEL_TAG_EXPRESSION, message) message = re.sub(USER_TAG_EXPRESSION, r"\1", message) message = re.sub(CHANNEL_TAG_EXPRESSION, r"\1", message) for match in matches: slack_name = await get_slack_name(match, client) message = message.replace(match, slack_name) resolved_message = re.sub(URL_EXPRESSION, r"\1", message) return resolved_message def invite_users_to_conversation(conversation_id: str, users_to_invite: list): """ Invites users to a provided conversation using a provided slack client with a channel token. Args: conversation_id: The slack conversation ID to invite the users to. users_to_invite: The user slack IDs to invite. """ for user in users_to_invite: try: body = {"channel": conversation_id, "users": user} send_slack_request_sync(CLIENT, "conversations.invite", body=body) except SlackApiError as e: message = str(e) if "already_in_channel" in message: continue elif message.find("cant_invite_self") == -1: raise def kick_users_from_conversation(conversation_id: str, users_to_kick: list): """ Kicks users from a provided conversation using a provided slack client with a channel token. Args: conversation_id: The slack conversation ID to kick the users from. users_to_kick: The user slack IDs to kick. """ for user in users_to_kick: try: body = {"channel": conversation_id, "user": user} send_slack_request_sync(CLIENT, "conversations.kick", body=body) except SlackApiError as e: message = str(e) if message.find("cant_invite_self") == -1: raise def mirror_investigation(): """ Updates the integration context with a new or existing mirror. """ if MIRRORING_ENABLED and not LONG_RUNNING_ENABLED: demisto.error( "Mirroring is enabled, however long running is disabled. For mirrors to work correctly," " long running must be enabled." ) args = demisto.args() demisto.debug(f"SlackV3 integration: This is the arguments for the mirror-investigation command: {args}") mirror_type = args.get("type", "all") auto_close = args.get("autoclose", "true") mirror_direction = args.get("direction", "both") mirror_to = args.get("mirrorTo", "group") channel_name = args.get("channelName", "") channel_topic = args.get("channelTopic", "") kick_admin = argToBoolean(args.get("kickAdmin", "false")) private = argToBoolean(args.get("private", "false")) investigation = demisto.investigation() demisto.debug(f"SlackV3 integration: This is the investigation - {investigation}") if investigation.get("type") == PLAYGROUND_INVESTIGATION_TYPE: return_error("Can not perform this action in playground.") integration_context = get_integration_context(SYNC_CONTEXT) demisto.debug(f"SlackV3 integration: This is the integration context - {integration_context}") if not integration_context or not integration_context.get("mirrors", []): mirrors: list = [] else: mirrors = json.loads(integration_context["mirrors"]) investigation_id = investigation.get("id") send_first_message = False current_mirror = list(filter(lambda m: m["investigation_id"] == investigation_id, mirrors)) channel_filter: list = [] if channel_name: channel_filter = list(filter(lambda m: m["channel_name"] == channel_name, mirrors)) demisto.debug(f"SlackV3 integration: This is the channel filter - {channel_filter}") if not current_mirror: channel_name = channel_name or f"incident-{investigation_id}" if not channel_filter: body = {"name": channel_name} if mirror_to != "channel" or private: body["is_private"] = True conversation = send_slack_request_sync( # type: ignore[var-annotated] CLIENT, "conversations.create", # type: ignore body=body, ).get("channel", {}) conversation_name = conversation.get("name") conversation_id = conversation.get("id") # Get the bot ID so we can invite him if integration_context.get("bot_id"): bot_id = integration_context["bot_id"] else: bot_id = get_bot_id() set_to_integration_context_with_retries({"bot_id": bot_id}, OBJECTS_TO_KEYS, SYNC_CONTEXT) invite_users_to_conversation(conversation_id, [bot_id]) # type: ignore send_first_message = True else: mirrored_channel = channel_filter[0] conversation_id = mirrored_channel["channel_id"] conversation_name = mirrored_channel["channel_name"] mirror = { "channel_id": conversation_id, "channel_name": conversation_name, "investigation_id": investigation.get("id"), "mirror_type": mirror_type, "mirror_direction": mirror_direction, "mirror_to": mirror_to, "auto_close": argToBoolean(auto_close), "mirrored": False, } else: mirror = mirrors.pop(mirrors.index(current_mirror[0])) conversation_id = mirror["channel_id"] # Special case (XSUP-70395): `type` is the only provided argument, combined as # "<mirror_type>:<mirror_direction>" (e.g. "all:ToDemisto"). Split it and keep the other values # from the existing mirror context instead of overriding them with defaults. if len(args) == 1 and "type" in args and ":" in mirror_type: mirror_type, mirror_direction = mirror_type.split(":", 1) auto_close = mirror.get("auto_close") mirror_to = mirror.get("mirror_to") demisto.debug( f"SlackV3 integration: 'type' was the only argument and contained a ':'. " f"Split into mirror_type='{mirror_type}', mirror_direction='{mirror_direction}'. " f"Kept auto_close={auto_close}, mirror_to={mirror_to} from the existing mirror context." ) if mirror_type: mirror["mirror_type"] = mirror_type if auto_close: mirror["auto_close"] = argToBoolean(auto_close) if mirror_direction: mirror["mirror_direction"] = mirror_direction if mirror_to and mirror["mirror_to"] != mirror_to: return_error("Cannot change the Slack channel type from XSOAR.") if channel_name: return_error("Cannot change the Slack channel name.") if channel_topic: return_error("Cannot change the Slack channel topic.") conversation_name = mirror["channel_name"] mirror["mirrored"] = False set_topic = False if channel_topic: set_topic = True else: mirror_name = f"incident-{investigation_id}" channel_filter = list(filter(lambda m: m["channel_name"] == conversation_name, mirrors)) if "channel_topic" in mirror: channel_topic = mirror["channel_topic"] elif channel_filter: channel_mirror = channel_filter[0] channel_topic = channel_mirror["channel_topic"] else: channel_topic = "" mirrored_investigations_ids = [f'incident-{m["investigation_id"]}' for m in channel_filter] if not channel_topic or channel_topic.find("incident-") != -1: new_topic = ", ".join(mirrored_investigations_ids + [mirror_name]) if channel_topic != new_topic: channel_topic = new_topic set_topic = True if set_topic: body = {"channel": conversation_id, "topic": channel_topic} send_slack_request_sync(CLIENT, "conversations.setTopic", body=body) mirror["channel_topic"] = channel_topic mirrors.append(mirror) demisto.debug(f"SlackV3 integration: This is the mirrors list - {mirrors}") if DISABLE_CACHING: set_to_integration_context_with_retries({"mirrors": mirrors}, OBJECTS_TO_KEYS, SYNC_CONTEXT) else: if not integration_context or not integration_context.get("conversations", []): conversations: list = [] else: conversations = json.loads(integration_context["conversations"]) conversations.append({"name": conversation_name, "id": conversation_id}) set_to_integration_context_with_retries( {"mirrors": mirrors, "conversations": conversations}, OBJECTS_TO_KEYS, SYNC_CONTEXT ) if send_first_message: server_links = demisto.demistoUrls() server_link = server_links.get("server") message = ( f"This channel was created to mirror incident {investigation_id}." f" \n View it on: {server_link}#/WarRoom/{investigation_id}" ) body = {"text": message, "channel": conversation_id} send_slack_request_sync(CLIENT, "chat.postMessage", body=body) if kick_admin: body = {"channel": conversation_id} send_slack_request_sync(CLIENT, "conversations.leave", body=body) demisto.results(f"Investigation mirrored successfully, channel: {conversation_name}") def long_running_loop(): tts = 15 if MIRRORING_ENABLED else 60 while True: error = "" try: if MIRRORING_ENABLED: check_for_mirrors() check_for_unanswered_questions() # Cleanup expired Assistant conversations if ENABLED_AI_ASSISTANT: slack_assistant_handler.delete_assistant_conversations_from_context() if EXTENSIVE_LOGGING: demisto.debug(f"Number of threads currently - {threading.active_count()}") stats, _ = slack_get_integration_context_statistics() demisto.debug(f"Integration Context Stats\n_____________\n{stats}") if SlackLog.messages: text = "Full Integration Log:\n" + "\n".join(SlackLog.messages) demisto.info(f"{text}") SlackLog.messages = [] time.sleep(tts) except requests.exceptions.ConnectionError as e: error = f"Could not connect to the Slack endpoint: {e!s}" except Exception as e: error = f"An error occurred: {e}" finally: demisto.updateModuleHealth("") if error: demisto.error(error) demisto.updateModuleHealth(error) def get_poll_minutes(current_time: datetime, sent: Optional[str]) -> float: """ Get the interval to wait before polling again in minutes. Args: current_time: The current time. sent: The time when the polling request was sent. Returns: Total minutes to wait before polling. """ poll_time_minutes = 1.0 if sent: sent_time = datetime.strptime(sent, DATE_FORMAT) total_delta = current_time - sent_time total_minutes = total_delta.total_seconds() / 60 for minute_range, interval in POLL_INTERVAL_MINUTES.items(): if len(minute_range) > 1 and total_minutes > minute_range[1]: continue poll_time_minutes = interval break return poll_time_minutes def answer_question(text: str, question: dict, email: str = ""): entitlement = question.get("entitlement", "") content, guid, incident_id, task_id = extract_entitlement(entitlement, text) try: demisto.handleEntitlementForUser(incident_id, guid, email, content, task_id) except Exception as e: demisto.debug(f"Failed handling entitlement {entitlement}: {e!s}") question["remove"] = True return incident_id def check_for_unanswered_questions(): integration_context = fetch_context() questions = integration_context.get("questions", None) if questions: questions = json.loads(questions) now = get_current_utc_time() now_string = datetime.strftime(now, DATE_FORMAT) updated_questions = [] for question in questions: if question.get("expiry"): # Check if the question expired - if it did, answer it with the default response # and remove it expiry = datetime.strptime(question["expiry"], DATE_FORMAT) if expiry < now: _ = answer_question(question.get("default_response"), question, email="") updated_questions.append(question) continue # Check if it has been enough time(determined by the POLL_INTERVAL_MINUTES parameter) # since the last polling time. if not, continue to the next question until it has. if question.get("last_poll_time"): last_poll_time = datetime.strptime(question["last_poll_time"], DATE_FORMAT) delta = now - last_poll_time minutes = delta.total_seconds() / 60 sent = question.get("sent", None) poll_time_minutes = get_poll_minutes(now, sent) if minutes < poll_time_minutes: continue question["last_poll_time"] = now_string updated_questions.append(question) if updated_questions: set_to_integration_context_with_retries({"questions": questions}, OBJECTS_TO_KEYS, SYNC_CONTEXT) def check_for_mirrors(): """ Checks for newly created mirrors and handles the mirroring process """ integration_context = fetch_context() if integration_context.get("mirrors"): mirrors = json.loads(integration_context["mirrors"]) updated_mirrors = [] updated_users = [] for mirror in mirrors: if not mirror["mirrored"]: investigation_id = mirror["investigation_id"] mirror = mirrors.pop(mirrors.index(mirror)) if mirror["mirror_to"] and mirror["mirror_direction"] and mirror["mirror_type"]: mirror_type = mirror["mirror_type"] auto_close = mirror["auto_close"] direction = mirror["mirror_direction"] channel_id = mirror["channel_id"] if isinstance(auto_close, str): auto_close = argToBoolean(auto_close) users: List[Dict] = demisto.mirrorInvestigation(investigation_id, f"{mirror_type}:{direction}", auto_close) if mirror_type != "none": try: invited_users = invite_to_mirrored_channel(channel_id, users) updated_users.extend(invited_users) except Exception as error: demisto.error(f"Could not invite investigation users to the mirrored channel: {error}") mirror["mirrored"] = True updated_mirrors.append(mirror) else: demisto.info(f"Could not mirror {investigation_id}") if updated_mirrors: context = {"mirrors": updated_mirrors} if updated_users: context["users"] = updated_users set_to_integration_context_with_retries(context, OBJECTS_TO_KEYS, SYNC_CONTEXT) return def invite_to_mirrored_channel(channel_id: str, users: List[Dict]) -> list: """ Invite the relevant users to a mirrored channel Args: channel_id: The mirrored channel users: The users to invite, each a dict of username and email Returns: users: The slack users that were invited """ slack_users = [] for user in users: user_email = user.get("email", "") user_name = user.get("username", "") # Try to invite by XSOAR email, if not found then by XSOAR user name slack_user = get_user_by_name(user_email, False) if user_email else None if not slack_user and user_name: slack_user = get_user_by_name(user_name, False) if slack_user: slack_users.append(slack_user) else: err_str = format_user_not_found_error(user=user_name) demisto.results({"Type": WARNING_ENTRY_TYPE, "Contents": err_str, "ContentsFormat": formats["text"]}) users_to_invite = [user.get("id") for user in slack_users] invite_users_to_conversation(channel_id, users_to_invite) return slack_users def extract_entitlement(entitlement: str, text: str) -> tuple[str, str, str, str]: """ Extracts entitlement components from an entitlement string Args: entitlement: The entitlement itself text: The actual reply text Returns: Entitlement components """ parts = entitlement.split("@") guid = parts[0] id_and_task = parts[1].split("|") incident_id = id_and_task[0] task_id = "" if len(id_and_task) > 1: task_id = id_and_task[1] content = text.replace(entitlement, "", 1) return content, guid, incident_id, task_id class SlackLogger(IntegrationLogger): def __init__(self): super().__init__() self.level = logging.INFO def info(self, message): text = self.encode(message) self.messages.append(text) def debug(self, message): text = self.encode(message) self.messages.append(text) def error(self, message): text = self.encode(message) self.messages.append(text) def exception(self, message): text = self.encode(message) self.messages.append(text) def warning(self, message): text = self.encode(message) self.messages.append(text) def set_logging_level(self, debug: bool = True): if debug: self.level = logging.DEBUG else: self.level = logging.INFO SlackLog = SlackLogger() async def slack_loop(): try: exception_await_seconds = 1 while True: SlackLog.set_buffering(state=True) SlackLog.set_logging_level(debug=EXTENSIVE_LOGGING) client = SocketModeClient( app_token=APP_TOKEN, web_client=ASYNC_CLIENT, logger=SlackLog, # type: ignore auto_reconnect_enabled=True, trace_enabled=EXTENSIVE_LOGGING, ) if not VERIFY_CERT: # SocketModeClient does not respect environment variables for ssl verification. # Instead we use a custom session. session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(verify_ssl=VERIFY_CERT)) client.aiohttp_client_session = session client.socket_mode_request_listeners.append(listen) # type: ignore try: await client.connect() # After successful connection, we reset the backoff time. exception_await_seconds = 1 await asyncio.sleep(float("inf")) except Exception as e: demisto.debug(f"Exception in long running loop, waiting {exception_await_seconds} - {e}") await asyncio.sleep(exception_await_seconds) exception_await_seconds *= 2 finally: try: await session.close() except Exception as e: demisto.debug(f"Failed to close client. - {e}") except Exception as e: demisto.error(f"An error has occurred while trying to create the socket client. {e}") async def handle_listen_error(error: str): """ Logs an error and updates the module health accordingly. Args: error: The error string. """ demisto.error(error) demisto.updateModuleHealth(error) async def start_listening(): """ Starts a Slack SocketMode client and checks for mirrored incidents. """ try: executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_in_executor(executor, long_running_loop) await slack_loop() except Exception as e: demisto.error(f"An error has occurred while gathering the loop tasks. {e}") async def handle_dm(user: dict, text: str, client: AsyncWebClient): """ Handles a direct message sent to the bot Args: user: The user who sent the message text: The message text client: The Slack client Returns: Text to return to the user """ message: str = text.lower() if message.find("incident") != -1 and ( message.find("create") != -1 or message.find("open") != -1 or message.find("new") != -1 ): user_email = user.get("profile", {}).get("email", "") user_name = user.get("name", "") demisto_user = demisto.findUser(email=user_email) if user_email else demisto.findUser(username=user.get("name")) if not demisto_user and not ALLOW_INCIDENTS: data = "You are not allowed to create incidents." else: try: data = await translate_create(text, user_name, user_email, demisto_user) except Exception as e: data = f"Failed creating incidents: {e!s}" else: try: data = demisto.directMessage(text, user.get("name"), user.get("profile", {}).get("email"), ALLOW_INCIDENTS) except Exception as e: data = str(e) if not data: data = "Sorry, I could not perform the selected operation." body = {"users": user.get("id")} im = await send_slack_request_async(client, "conversations.open", body=body) channel = im.get("channel", {}).get("id") # type: ignore body = {"text": data, "channel": channel} await send_slack_request_async(client, "chat.postMessage", body=body) def update_integration_context_samples(incidents: list, max_samples: int = MAX_SAMPLES): """ Updates the integration context samples with the newly created incident. If the size of the samples has reached `MAX_SAMPLES`, will pop out the latest sample. Args: incidents (list): The list of the newly created incidents. max_samples (int): Max samples size. """ ctx = get_integration_context() updated_samples_list: List[Dict] = incidents + ctx.get("samples", []) ctx["samples"] = updated_samples_list[:max_samples] set_integration_context(ctx) def add_req_data_to_incidents(incidents: list, request_fields: dict) -> list: """ Adds the request_fields as a rawJSON to every created incident for further information on the incident """ for incident in incidents: incident["rawJSON"] = json.dumps(request_fields) return incidents async def translate_create(message: str, user_name: str, user_email: str, demisto_user: dict) -> str: """ Processes an incident creation message Args: message: The creation message user_name: The name of the user in Slack user_email: The email of the user in Slack demisto_user: The demisto user associated with the request (if exists) Returns: Creation result """ json_pattern = r"(?<=json=).*" name_pattern = r"(?<=name=).*" type_pattern = r"(?<=type=).*" message = message.replace("\n", "").replace("`", "") json_match = re.search(json_pattern, message) created_incident = None data = "" user_demisto_id = "" request_fields = {"ReporterEmail": user_email, "Message": message} incidents = [] if demisto_user: user_demisto_id = demisto_user.get("id", "") if json_match: if re.search(name_pattern, message) or re.search(type_pattern, message): data = "No other properties other than json should be specified." else: incidents_json = json_match.group() incidents = json.loads(incidents_json.replace("“", '"').replace("”", '"')) if not isinstance(incidents, list): incidents = [incidents] add_req_data_to_incidents(incidents, request_fields) created_incident = await create_incidents(incidents, user_name, user_email, user_demisto_id) if not created_incident: data = "Failed creating incidents." else: name_match = re.search(name_pattern, message) if not name_match: data = "Please specify arguments in the following manner: name=<name> type=[type] or json=<json>." else: incident_name = re.sub("type=.*", "", name_match.group()).strip() incident_type = "" type_match = re.search(type_pattern, message) if type_match: incident_type = re.sub("name=.*", "", type_match.group()).strip() incident = {"name": incident_name} incident_type = incident_type or INCIDENT_TYPE if incident_type: incident["type"] = incident_type incidents = add_req_data_to_incidents([incident], request_fields) created_incident = await create_incidents([incident], user_name, user_email, user_demisto_id) if not created_incident: data = "Failed creating incidents." if created_incident: demisto.debug(f"Created {len(incidents)} incidents") update_integration_context_samples(incidents) if isinstance(created_incident, list): created_incident = created_incident[0] server_links = demisto.demistoUrls() server_link = server_links.get("server") incident_name = created_incident["name"] incident_id = created_incident["id"] data = f"Successfully created incident {incident_name}.\n View it on: {server_link}#/WarRoom/{incident_id}" return data async def create_incidents(incidents: list, user_name: str, user_email: str, user_demisto_id: str = "") -> dict: """ Creates incidents according to a provided JSON object Args: incidents: The incidents JSON user_name: The name of the user in Slack user_email: The email of the user in Slack user_demisto_id: The id of demisto user associated with the request (if exists) Returns: The creation result """ for incident in incidents: # Add relevant labels to context labels = incident.get("labels", []) keys = [label.get("type") for label in labels] if "Reporter" not in keys: labels.append({"type": "Reporter", "value": user_name}) if "ReporterEmail" not in keys: labels.append({"type": "ReporterEmail", "value": user_email}) if "Source" not in keys: labels.append({"type": "Source", "value": "Slack"}) incident["labels"] = labels data = demisto.createIncidents(incidents, userID=user_demisto_id) if user_demisto_id else demisto.createIncidents(incidents) return data def is_bot_message(data: dict) -> bool: """ Determines if the message received was created by a bot or not. :param data: dict: The payload sent with the message :return: bool: True indicates the message was from a Bot, False indicates it was from an individual """ subtype = data.get("subtype", "") message_bot_id = data.get("bot_id", "") event: dict = data.get("event", {}) if subtype == "bot_message" or message_bot_id or event.get("bot_id", None): return True elif event.get("subtype") == "bot_message": return True return bool(not (event.get("user") or data.get("user", {}).get("id") or data.get("envelope_id"))) async def get_user_details(user_id: str) -> AsyncSlackResponse: """ Performs the retrieval of the User object from the UserID which is sent as part of the payload. :param user_id: str: The ID of the user to perform the lookup on. :return: AsyncSlackResponse: An AsyncSlackResponse object which is a dictionary of the user object. """ user = await ASYNC_CLIENT.users_info(user=user_id) return user.get("user", {}) # type: ignore def search_text_for_entitlement(text: str, user: AsyncSlackResponse) -> str: """ In some cases, a user may send an entitlement string as part of the text, this function will search the text to see if an entitlement string exists, and if so, will handle the entitlement and return a default reply :param text: str: The text message found in the events payload :param user: AsyncSlackResponse: The user object returned from get_user_details :return: str: Returns a default response if an entitlement is found, otherwise an empty string. """ entitlement_match = re.search(ENTITLEMENT_REGEX, text) if entitlement_match: content, guid, incident_id, task_id = extract_entitlement(entitlement_match.group(), text) demisto.handleEntitlementForUser(incident_id, guid, user.get("profile", {}).get("email"), content, task_id) # type: ignore return "Thank you for your response." else: return "" async def process_entitlement_reply( entitlement_reply: str, user_id: str, action_text: str, response_url: str | None = None, channel: str | None = None, message_ts: str | None = None, ): """ Triggered when an entitlement reply is found, this function will update the original message with the reply message. :param entitlement_reply: str: The text to update the asking question with. :param user_id: str: ID of the user who answered the entitlement :param action_text: str: The text attached to the button, used for string replacement. :param response_url: str: The response URL to use for the update. :param channel: str: The channel ID of where the question exists. :param message_ts: str: The timestamp of the message. Acts as a unique ID. :return: None """ if "{user}" in entitlement_reply: entitlement_reply = entitlement_reply.replace("{user}", f"<@{user_id}>") if "{response}" in entitlement_reply and action_text: entitlement_reply = entitlement_reply.replace("{response}", str(action_text)) if response_url: requests.post(response_url, json={"text": entitlement_reply, "replace_original": True}) else: await send_slack_request_async( client=ASYNC_CLIENT, method="chat.update", body={"channel": channel, "ts": message_ts, "text": entitlement_reply, "blocks": []}, ) def is_dm(channel: str) -> bool: """ Takes the channel ID and will see if the first letter of the ID is 'D'. If so, we know it's from a direct message. :param channel: str: The channel ID to check. :return: bool: Boolean indicating if the channel is a DM or not. """ return bool(channel and channel[0] == "D" and ENABLE_DM) async def process_mirror(channel_id: str, text: str, user: AsyncSlackResponse): """ Process messages which have been identified as possible mirrored messages. If so, we grab the context (cached), and check for a match of the channel_id in the cached mirrors. If we find one, we will update the mirror object and send the message to the corresponding investigation's war room as an entry. :param channel_id: str: The ID of the channel for the message event we recieved. :param text: str: The text of the message event to be sent to a war room. :param user: AsyncSlackResponse: The user object for the user who sent the message. :return: None """ integration_context = fetch_context() if not integration_context or "mirrors" not in integration_context: return mirrors = json.loads(integration_context["mirrors"]) mirror_filter = list(filter(lambda m: m["channel_id"] == channel_id, mirrors)) if not mirror_filter: return for mirror in mirror_filter: if mirror["mirror_direction"] == "FromDemisto" or mirror["mirror_type"] == "none": return if not mirror["mirrored"]: # In case the investigation is not mirrored yet mirror = mirrors.pop(mirrors.index(mirror)) if mirror["mirror_to"] and mirror["mirror_direction"] and mirror["mirror_type"]: investigation_id = mirror["investigation_id"] mirror_type = mirror["mirror_type"] auto_close = mirror["auto_close"] direction = mirror["mirror_direction"] if isinstance(auto_close, str): auto_close = argToBoolean(auto_close) demisto.info(f"Mirroring: {investigation_id}") demisto.mirrorInvestigation(investigation_id, f"{mirror_type}:{direction}", auto_close) mirror["mirrored"] = True mirrors.append(mirror) set_to_integration_context_with_retries({"mirrors": mirrors}, OBJECTS_TO_KEYS, SYNC_CONTEXT) investigation_id = mirror["investigation_id"] await handle_text(ASYNC_CLIENT, investigation_id, text, user) # type: ignore def fetch_context(force_refresh: bool = False) -> dict: """ Fetches the integration instance context from the server if the CACHE_EXPIRY is smaller than the current epoch time In the event that the cache is not expired, we return a cached copy of the context which has been stored in memory. We can force the retrieval of the updated context by setting the force_refresh flag to True. :param force_refresh: bool: Indicates if the context should be refreshed regardless of the expiry time. :return: dict: Either a cached copy of the integration context, or the context itself. """ global CACHED_INTEGRATION_CONTEXT, CACHE_EXPIRY now = int(datetime.now(UTC).timestamp()) if (now >= CACHE_EXPIRY) or force_refresh: demisto.debug( f"Cached context has expired or forced refresh. forced refresh value is {force_refresh}. Fetching new context" ) CACHE_EXPIRY = next_expiry_time() CACHED_INTEGRATION_CONTEXT = get_integration_context(SYNC_CONTEXT) return CACHED_INTEGRATION_CONTEXT def handle_newly_created_channel(creator, channel): """ Only triggered on a created_channel event type, this function checks if the creator of the channel was the bot or user and if so, will check if the channel is in the cached context. If the channel is not in the context already we will refresh the context. :param channel: The Channel ID of the created channel. :param creator: User ID of the creator of the new channel. :return: None """ if creator == BOT_ID: if "mirrors" in CACHED_INTEGRATION_CONTEXT: mirrors = json.loads(CACHED_INTEGRATION_CONTEXT["mirrors"]) if len(mirrors) == 0: fetch_context(force_refresh=True) return mirror_filter = list(filter(lambda m: m["channel_id"] == channel, mirrors)) if not mirror_filter: fetch_context(force_refresh=True) return else: fetch_context(force_refresh=True) return else: return def reset_listener_health(): demisto.updateModuleHealth("") demisto.info("SlackV3 - Event handled successfully.") async def handle_entitlement_interactions( data: dict, event: dict, user_id: str, actions: list, channel: str, thread: Optional[str], message_ts: str, quick_check_payload: str, user_profile: dict, ) -> bool: """ Handles entitlement-related interactions (SlackAsk responses). Args: data: The payload data event: The event data user_id: The user ID actions: List of actions channel: The channel ID thread: The thread timestamp message_ts: The message timestamp quick_check_payload: JSON string of the payload Returns: True if entitlement was handled, False otherwise """ action_text = "" # Quick check for entitlement if re.search(ENTITLEMENT_REGEX, quick_check_payload): # At this point, we know there is an entitlement in the payload. # This is a check to determine if the event contains actions which are sent as part of a SlackAsk response. entitlement_reply = None user = await get_user_details(user_id=user_id) if len(actions) > 0: entitlement_json = actions[0].get("value") if entitlement_json is None: return True entitlement_string = json.loads(entitlement_json) if actions[0].get("action_id") == "xsoar-button-submit": demisto.debug("Handling a SlackBlockBuilder response.") state = data.get("state", {}) if state: # Add user profile information to state object state.update({"xsoar-button-submit": "Successful", "submitting_user": user_profile}) action_text = json.dumps(state) else: demisto.debug("Not handling a SlackBlockBuilder response.") action_text = actions[0].get("text").get("text") _ = answer_question(action_text, entitlement_string, user.get("profile", {}).get("email")) # type: ignore entitlement_reply = entitlement_string.get("reply", "Thank you for your reply.") if entitlement_reply: response_url = data.get("response_url", "") await process_entitlement_reply(entitlement_reply, user_id, action_text, response_url=response_url) reset_listener_health() return True return False async def handle_assistant_interactions( data: dict, event: dict, user_id: str, actions: list, data_type: str, ): """ Handles Assistant AI interactions. Args: data: The payload data event: The event data user_id: The user ID actions: List of actions data_type: The type of data """ if not ENABLED_AI_ASSISTANT: return # Check if this is a modal submission (e.g., feedback modal) view = data.get("view", {}) is_modal_submission = is_assistant_modal_submission(data_type, view) integration_context = get_integration_context(SYNC_CONTEXT) bot_id = json.loads(integration_context.get("bot_id", '""')) if not bot_id: bot_id = get_bot_id() is_assistant_action = is_assistant_interactive_response(actions) text = event.get("text", "") is_bot_mentioned = is_bot_mention(text, bot_id, event) # Only proceed if bot was mentioned or it's an assistant action if is_bot_mentioned or is_assistant_action or is_modal_submission: # Get user details and prepare data user = await get_user_details(user_id=user_id) user_email = user.get("profile", {}).get("email", "") # type: ignore[call-overload] container = data.get("container", {}) channel_id = event.get("channel", "") or container.get("channel_id", "") thread_ts = event.get("thread_ts", "") or event.get("ts", "") or container.get("thread_ts", "") assistant_context = json.loads(integration_context.get(slack_assistant_handler.CONTEXT_KEY, "{}")) assistant_id_key = f"{channel_id}_{thread_ts}" demisto.debug( f"Assistant interaction: user={user_email}, channel={channel_id}, thread={thread_ts}, " f"bot_mentioned={is_bot_mentioned}, action={is_assistant_action}, modal={is_modal_submission}" ) # Track original assistant to detect changes original_assistant = json.dumps(assistant_context, sort_keys=True) # Handle bot mention using the handler if is_bot_mentioned: message_ts = event.get("ts", "") assistant_context = await slack_assistant_handler.handle_bot_mention( text, user_id, user_email, channel_id, thread_ts, assistant_context, assistant_id_key, bot_id, message_ts ) # Handle interactive actions (agent selection or approval) elif is_assistant_action: trigger_id = data.get("trigger_id", "") message = data.get("message", {}) assistant_context = await slack_assistant_handler.handle_action( actions, user_id, user_email, channel_id, thread_ts, message, assistant_context, assistant_id_key, trigger_id, bot_id=bot_id, ) elif is_modal_submission: await handle_assistant_modal_submission(view, user_id, user_email, slack_assistant_handler) # Save updated assistant context only if it was actually modified updated_assistant = json.dumps(assistant_context, sort_keys=True) if updated_assistant != original_assistant: demisto.debug("Assistant context modified, saving to integration context") set_to_integration_context_with_retries( {slack_assistant_handler.CONTEXT_KEY: assistant_context, "bot_id": bot_id}, OBJECTS_TO_KEYS, SYNC_CONTEXT ) async def listen(client: SocketModeClient, req: SocketModeRequest): """ This is the main listener which is attached to the open socket connection. When a SocketModeRequest has been received this flow will be triggered. The Payload can be of any type and this function will determine what type it is, then handle it accordingly. In the event that the request is nothing of interest to us, we will simply acknowledge the request, process the message and gracefully ignore it. :param client: SocketModeClient: This is the socket client which is created by the slack_loop function. :param req: SocketModeRequest: The request object which has been sent by Slack. :return: None """ demisto.debug("Starting to process message") if req.envelope_id: response = SocketModeResponse(envelope_id=req.envelope_id) await client.send_socket_mode_response(response) if req.retry_attempt: if req.retry_attempt > 0 and IGNORE_RETRIES: demisto.debug("Slack is resending the message. To prevent double posts, the retry is ignored.") return else: demisto.debug( f"Slack is resending the message. Ignore retries is - {IGNORE_RETRIES} and the " f"retry attempt is - {req.retry_attempt}. Continuing to process the event." ) data_type: str = req.type payload: dict = req.payload if data_type == "error": error = payload.get("error", {}) error_code = error.get("code") error_msg = error.get("msg") await handle_listen_error(f"Slack API has thrown an error. Code: {error_code}, Message: {error_msg}.") return try: data: dict = req.payload event: dict = data.get("event", {}) text = event.get("text", "") user_profile = data.get("user", {}) user_id = user_profile.get("id", "") if not user_id: user_id = event.get("user", "") channel = event.get("channel", "") thread = event.get("thread_ts", None) message = data.get("message", {}) action_text = "" message_ts = message.get("ts", "") actions = data.get("actions", []) quick_check_payload = json.dumps(data) # Check if the message is from a bot so we can quit processing ASAP if is_bot_message(data): return # Handle entitlement interactions (SlackAsk) entitlement_handled = await handle_entitlement_interactions( data, event, user_id, actions, channel, thread, message_ts, quick_check_payload, user_profile ) # Handle Assistant AI interactions await handle_assistant_interactions(data, event, user_id, actions, data_type) if entitlement_handled: # Entitlement was handled, stop processing return # Check if slash command received. If so, ignore for now. if data.get("command", None): return # Check to see if the event is about a newly handled event. elif event.get("type") == "channel_created" and MIRRORING_ENABLED: creator = event.get("channel", {}).get("creator", "") channel_id = event.get("channel", {}).get("id", "") handle_newly_created_channel(creator=creator, channel=channel_id) return # Sometimes we can receive message_changed events, we currently do not support these. if event.get("subtype") == "message_changed": return # Check if the message is being sent directly to our bot. if is_dm(channel): user = await get_user_details(user_id=user_id) await handle_dm(user, text, ASYNC_CLIENT) # type: ignore reset_listener_health() return # If a thread_id is found in the payload, we will check if it is a reply to a SlackAsk task. Currently threads # are not mirrored if thread: user = await get_user_details(user_id=user_id) entitlement_reply = await check_and_handle_entitlement(text, user, thread) # type: ignore if entitlement_reply: await process_entitlement_reply(entitlement_reply, user_id, action_text, channel=channel, message_ts=message_ts) reset_listener_health() return # If a message has made it here, we need to check if the message is being mirrored. If not, we will ignore it. if MIRRORING_ENABLED: user = await get_user_details(user_id=user_id) await process_mirror(channel, text, user) reset_listener_health() return except Exception: await handle_listen_error(f"Error occurred while listening to Slack:\n{traceback.format_exc()}") async def get_user_by_id_async(client: AsyncWebClient, user_id: str) -> dict: """ Get the details of a slack user by id asynchronously. Args: client: The slack web client to use. user_id: The id of the user. Returns: The slack user. """ body = {"user": user_id} return (await send_slack_request_async(client, "users.info", http_verb="GET", body=body)).get("user", {}) async def handle_text(client: AsyncWebClient, investigation_id: str, text: str, user: dict): """ Handles text received in the Slack workspace (not DM) Args: client: The Slack client investigation_id: The mirrored investigation ID text: The received text user: The sender """ if text: demisto.addEntry( id=investigation_id, entry=await clean_message(text, client), username=user.get("name", ""), email=user.get("profile", {}).get("email", ""), footer=MESSAGE_FOOTER, ) async def check_and_handle_entitlement(text: str, user: dict, thread_id: str) -> str: """ Handles an entitlement message (a reply to a question) Args: text: The message text user: The user who sent the reply thread_id: The thread ID Returns: If the message contains entitlement, return a reply. """ integration_context = fetch_context() questions = integration_context.get("questions", []) if questions and thread_id: questions = json.loads(questions) question_filter = list(filter(lambda q: q.get("thread") == thread_id, questions)) if question_filter: question = question_filter[0] entitlement = question.get("entitlement") reply = question.get("reply", "Thank you for your response.") content, guid, incident_id, task_id = extract_entitlement(entitlement, text) demisto.handleEntitlementForUser(incident_id, guid, user.get("profile", {}).get("email"), content, task_id) question["remove"] = True set_to_integration_context_with_retries({"questions": questions}, OBJECTS_TO_KEYS, SYNC_CONTEXT) return reply return "" """ SEND """ def search_conversation_in_params(conversation_to_search): if conversation_to_search in COMMON_CHANNELS: return {"name": conversation_to_search, "id": COMMON_CHANNELS[conversation_to_search]} return None def search_conversation_in_context(conversation_to_search): """ Some channels are stored in the context if they were created by the integration or they are mirrored channels. This will attempt to find the conversation in the context. Args: conversation_to_search: The conversation name we are searching for. Returns: if found, the conversation dictionary will be returned. """ conversation: dict = {} integration_context = get_integration_context(SYNC_CONTEXT) conversations = integration_context.get("conversations") if conversations: conversations = json.loads(conversations) conversation_filter = list(filter(lambda c: conversation_to_search == c.get("name", "").lower(), conversations)) if conversation_filter: conversation = conversation_filter[0] else: demisto.debug(f'Could not find slack channel "{conversation_to_search}" in integration context, searching via API') else: demisto.debug(f"No conversations were found in the context while searching for {conversation_to_search}") return conversation def get_conversation_from_api_paginated(conversation_to_search): """ Searches the Slack API for the conversation. Used only when DISABLE_CACHING is false. Args: conversation_to_search: The conversation name that we are searching for. Returns: A conversation object containing only the conversation name and ID if found. """ body = {"types": "private_channel,public_channel", "exclude_archived": True, "limit": PAGINATED_COUNT} response = send_slack_request_sync(CLIENT, "conversations.list", http_verb="GET", body=body) while True: conversations = response["channels"] if response and response.get("channels") else [] cursor = response.get("response_metadata", {}).get("next_cursor") # type: ignore conversation_filter = list(filter(lambda c: c.get("name").lower() == conversation_to_search, conversations)) if conversation_filter: break if not cursor: demisto.info("Reached the end of looking for a channel") break body = body.copy() # strictly for unit-test purposes (test_get_conversation_by_name_paging) body.update({"cursor": cursor}) response = send_slack_request_sync(CLIENT, "conversations.list", http_verb="GET", body=body) if conversation_filter: conversation = conversation_filter[0] return {"name": conversation.get("name"), "id": conversation.get("id")} else: return {} def save_conversation_to_context(conversation): """ Pulls the context and will insert the conversation. Used only when DISABLE_CACHING is false as it can cause the context to grow to unsustainable sizes. Args: conversation: The conversation to be inserted. Should contain ONLY the conversation name and ID """ integration_context = get_integration_context(SYNC_CONTEXT) if conversation: conversations = integration_context.get("conversations") if conversations: conversations = json.loads(conversations) conversations.append(conversation) else: conversations = [conversation] set_to_integration_context_with_retries({"conversations": conversations}, OBJECTS_TO_KEYS, SYNC_CONTEXT) def get_conversation_by_name(conversation_name: str) -> dict: """ Get a slack conversation by its name. Order of operation is: 1. Check the COMMON_CHANNEL parameter for the conversation 2. Check the integration context for the conversation 3. If DISABLE_CACHING is false, then we will paginate the api 4. If DISABLE_CACHING is false, then we will save the results of the pagination to context Args: conversation_name: The conversation name Returns: The slack conversation """ conversation_to_search = conversation_name.lower() conversation: dict = {} # Checks if the channel is defined in the integration params if len(COMMON_CHANNELS) > 0: conversation = search_conversation_in_params(conversation_to_search) if not DISABLE_CACHING: # Find conversation in the cache if DISABLE_CACHING is false. if not conversation: conversation = search_conversation_in_context(conversation_to_search) # Find conversation in the api if DISABLE_CACHING is false. if not conversation: conversation = get_conversation_from_api_paginated(conversation_to_search) # Save conversation to cache save_conversation_to_context(conversation) return conversation def send_mirrored_file_to_slack(entry: str, message: str, original_channel: str, channel_id: str, comment: Optional[str] = None): """ Sends a file from xsoar investigation to a mirrored slack channel Args: entry: the entry ID of the file message: the message from the war-room when uploading file original_channel: the channel name to upload the file channel_id: the channel ID to upload the file comment: a comment that was added when uploading the file """ file_name = demisto.getFilePath(entry)["name"] if FILE_MIRRORING_ENABLED: demisto.debug( f"file {file_name} has been uploaded to a mirrored incident, " f"uploading the file to slack channel {original_channel}" ) if comment: # if a comment was added when uploading the file, add it to the message message = f"{message}\nComment: {comment}" slack_send_file(original_channel, channel_id, entry, message) else: demisto.debug(f"file {file_name} will not be mirrored because file mirroring is not enabled") def slack_send(): """ Sends a message to slack """ args = demisto.args() message = args.get("message", "") to = args.get("to") original_channel = args.get("channel") channel_id = demisto.args().get("channel_id", "") group = args.get("group") message_type = args.get("messageType", "") # From server original_message = args.get("originalMessage", "") # From server entry = args.get("entry") ignore_add_url = args.get("ignoreAddURL", False) or args.get("IgnoreAddURL", False) thread_id = args.get("threadID", "") severity = args.get("severity") # From server blocks = args.get("blocks") entry_object = args.get("entryObject") # From server, available from demisto v6.1 and above entitlement = "" if message_type and (message_type not in PERMITTED_NOTIFICATION_TYPES) and message_type != MIRROR_TYPE: demisto.info(f"Message type is not in permitted options. Received: {message_type}") return if message_type == MIRROR_TYPE and original_message.find(MESSAGE_FOOTER) != -1: # return so there will not be a loop of messages return if message_type == MIRROR_TYPE: tags = argToList(demisto.params().get("filtered_tags", [])) entry_tags = entry_object.get("tags", []) if tags and not entry_tags: return # return if the entry tags is not containing any of the filtered_tags if tags and not any(elem in entry_tags for elem in tags): return is_file = bool(entry_object.get("fileID", None)) if is_file: send_mirrored_file_to_slack( entry, message=message, original_channel=original_channel, channel_id=channel_id, comment=entry_object.get("contents"), ) return if (to and group) or (to and original_channel) or (to and original_channel and group): return_error("Only one destination can be provided.") if severity: try: severity = int(severity) except Exception: severity = None channel = original_channel if original_channel == INCIDENT_NOTIFICATION_CHANNEL or (not original_channel and message_type == INCIDENT_OPENED): original_channel = INCIDENT_NOTIFICATION_CHANNEL channel = DEDICATED_CHANNEL demisto.debug(f"trying to send message to channel {original_channel}, changing slack channel to {channel}") if ( channel == DEDICATED_CHANNEL and original_channel == INCIDENT_NOTIFICATION_CHANNEL and ((severity is not None and severity < SEVERITY_THRESHOLD) or not (len(CUSTOM_PERMITTED_NOTIFICATION_TYPES) > 0)) ): channel = None demisto.debug(f"Severity of the notification is - {severity} and the Severity threshold is {SEVERITY_THRESHOLD}") if not (to or group or channel or channel_id): return_error("Either a user, group, channel id, or channel must be provided.") reply = "" expiry = "" default_response = "" if blocks: entitlement_match = re.search(ENTITLEMENT_REGEX, blocks) if entitlement_match: try: parsed_message = json.loads(blocks) entitlement = parsed_message.get("entitlement") blocks = parsed_message.get("blocks") reply = parsed_message.get("reply") expiry = parsed_message.get("expiry") default_response = parsed_message.get("default_response") except Exception: demisto.info("Slack - could not parse JSON from entitlement blocks.") elif message: entitlement_match = re.search(ENTITLEMENT_REGEX, message) if entitlement_match: try: parsed_message = json.loads(message) entitlement = parsed_message.get("entitlement") message = parsed_message.get("message") reply = parsed_message.get("reply") expiry = parsed_message.get("expiry") default_response = parsed_message.get("default_response") except Exception: demisto.info("Slack - could not parse JSON from entitlement message.") response = slack_send_request( to, channel, group, entry, ignore_add_url, thread_id, message=message, blocks=blocks, channel_id=channel_id ) if response: thread = response.get("ts") if entitlement: save_entitlement(entitlement, thread, reply, expiry, default_response) demisto.results( { "Type": entryTypes["note"], "HumanReadable": f"Message sent to Slack successfully.\nThread ID is: {thread}", "Contents": response.data, "ContentsFormat": formats["json"], "EntryContext": { "Slack.Thread(val.ID===obj.ID)": {"ID": thread}, }, } ) else: demisto.results("Could not send the message to Slack.") def save_entitlement(entitlement, thread, reply, expiry, default_response): """ Saves an entitlement with its thread Args: entitlement: The entitlement thread: The thread reply: The reply to send to the user. expiry: The question expiration date. default_response: The response to send if the question times out. """ integration_context = get_integration_context(SYNC_CONTEXT) questions = integration_context.get("questions", []) if questions: questions = json.loads(integration_context["questions"]) questions.append( { "thread": thread, "entitlement": entitlement, "reply": reply, "expiry": expiry, "sent": datetime.strftime(get_current_utc_time(), DATE_FORMAT), "default_response": default_response, } ) set_to_integration_context_with_retries({"questions": questions}, OBJECTS_TO_KEYS, SYNC_CONTEXT) SEND_FILE_ERROR_EXPLANATIONS = { "access_denied": "Access to a resource specified in the request is denied.", "channel_not_found": "The value passed for channel_id was invalid.", "file_not_found": "Could not find the file from the upload ticket.", "file_update_failed": "Failure occurred when attempting to update the file.", "invalid_channel": "The channel could not be found or the channel specified is invalid.", "posting_to_channel_denied": "The user is not authorized to post to the target channel(s).", "account_inactive": "The authentication token is for a deleted user or workspace when using a bot token.", "deprecated_endpoint": "The endpoint has been deprecated.", "ekm_access_denied": "Administrators have suspended the ability to post a message.", "enterprise_is_restricted": "The method cannot be called from an Enterprise.", "invalid_auth": "The provided token is invalid or the request originates from a disallowed IP address.", "method_deprecated": "The method has been deprecated.", "missing_scope": "The token used is not granted the specific scope permissions required to complete this request.", "not_allowed_token_type": "The token type used in this request is not allowed.", "not_authed": "No authentication token provided.", "not_in_channel": "The user or bot used is not in the target channel(s). Ensure they are invited to the channel(s).", "no_permission": "The workspace token used in this request does not have the permissions necessary to complete the request.", "org_login_required": "The workspace is undergoing an enterprise migration and is temporarily unavailable.", "token_expired": "The authentication token has expired.", "token_revoked": "The authentication token is for a deleted user or workspace or the app has been removed.", "two_factor_setup_required": "Two factor setup is required.", "team_access_not_granted": "The token used is not granted the specific workspace access required to complete this request.", "accesslimited": "Access to this method is limited on the current network.", "fatal_error": "The Slack server could not complete this operation(s).", "internal_error": "The Slack server could not complete this operation(s), likely due to a transient issue on our end.", "ratelimited": "The request has been rate limited.", "request_timeout": "Data was either missing or truncated for the POST request.", "service_unavailable": "The Slack service is temporarily unavailable.", "team_added_to_org": "The Slack workspace is currently undergoing migration to an Enterprise Organization.", } def slack_send_file(_channel: str | None = None, _channel_id: str = "", _entry_id: str | None = None, _comment: str = ""): """ Sends a file to slack """ to = demisto.args().get("to") channel = _channel or demisto.args().get("channel") channel_id = _channel_id or demisto.args().get("channel_id", "") group = demisto.args().get("group") entry_id = _entry_id or demisto.args().get("file") thread_id = demisto.args().get("threadID") comment = _comment or demisto.args().get("comment", "") if not (to or channel or group): mirror = find_mirror_by_investigation() if mirror: channel = mirror.get("channel_name") if not (to or channel or group or channel_id): return_error("Either a user, group, channel id or channel must be provided.") file_path = demisto.getFilePath(entry_id) file_dict = {"path": file_path["path"], "name": file_path["name"], "comment": comment} error_message = f'Failed to send file: {file_path["name"]} to Slack.' try: response = slack_send_request(to, channel, group, thread_id=thread_id, file_dict=file_dict, channel_id=channel_id) if response: return_results(CommandResults(readable_output="File sent to Slack successfully.")) else: raise DemistoException(message=error_message) except SlackApiError as e: demisto.debug(f"{error_message} {e}") if error_code := e.response.get("error"): error_explanation = SEND_FILE_ERROR_EXPLANATIONS.get(error_code, error_code.replace("_", " ").capitalize()) error_message += f" {error_explanation}" raise DemistoException(message=error_message) def handle_tags_in_message_sync(message: str) -> str: """ Handles user tags in a slack send message Args: message: The slack message Returns: The tagged slack message """ matches = re.finditer(USER_TAG_EXPRESSION, message) for match in matches: tag = match.group(1) # If the tag is already a resolved Slack user/bot ID (e.g. "<@U12345678>"), # skip the expensive get_user_by_name lookup — the mention is already valid. if SLACK_USER_ID_EXPRESSION.match(tag): continue slack_user = get_user_by_name(tag) if slack_user: message = message.replace(match.group(0), f"<@{slack_user.get('id')}>") else: # Only strip the brackets of this specific unresolved tag, not every tag in the # message (a blanket re.sub would also break already-valid ID mentions we skipped). message = message.replace(match.group(0), tag) return message def send_message( destinations: list, entry: str, ignore_add_url: bool, integration_context: dict, message: str, thread_id: str, blocks: str ): """ Sends a message to Slack. Args: destinations: The destinations to send to. entry: A WarRoom entry to send. ignore_add_url: Do not add a XSOAR URL to the message. integration_context: Current integration context. message: The message to send. thread_id: The Slack thread ID to send the message to. blocks: Message blocks to send Returns: The Slack send response. """ if not message: if blocks: message = "New message from SOC Bot" # This is shown in the notification bubble from Slack else: message = "\n" if message and not blocks: if ignore_add_url and isinstance(ignore_add_url, str): ignore_add_url = argToBoolean(ignore_add_url) if not ignore_add_url: investigation = demisto.investigation() server_links = demisto.demistoUrls() if investigation: if investigation.get("type") != PLAYGROUND_INVESTIGATION_TYPE: link = server_links.get("warRoom") if link: link = get_war_room_url(link) if entry: link += "/" + entry message += f"\nView it on: {link}" else: link = server_links.get("server", "") if link: message += f"\nView it on: {link}#/home" try: response = send_message_to_destinations(destinations, message, thread_id, blocks) except SlackApiError as e: if str(e).find("not_in_channel") == -1 and str(e).find("channel_not_found") == -1: raise bot_id = integration_context.get("bot_id") if not bot_id: bot_id = get_bot_id() for dest in destinations: invite_users_to_conversation(dest, [bot_id]) response = send_message_to_destinations(destinations, message, thread_id, blocks) return response def send_message_to_destinations( destinations: list, message: str, thread_id: str, blocks: str | list = "", attachments: list | None = None, ephemeral_message: bool = False, user_id: str = "", bot_name: str = "", ) -> Optional[SlackResponse]: """ Sends a message to provided destinations Slack. Args: destinations: Destinations to send to. message: The message to send. thread_id: Slack thread ID to send to. blocks: Message blocks to send attachments: Optional attachments to include ephemeral_message: Whether to send as ephemeral (only visible to specific user) user_id: User ID for ephemeral messages (required if ephemeral_message=True) bot_name: Optional name for the bot. Returns: The Slack send response. """ response: Optional[SlackResponse] = None body: dict = {} if message: clean_message = handle_tags_in_message_sync(message) body["text"] = clean_message if blocks: if isinstance(blocks, str): body["blocks"] = json.loads(blocks, strict=False) else: body["blocks"] = blocks if attachments: body["attachments"] = attachments if thread_id: body["thread_ts"] = thread_id post_method = "chat.postEphemeral" if ephemeral_message else "chat.postMessage" for destination in destinations: body["channel"] = destination if ephemeral_message: if user_id: body["user"] = user_id else: demisto.info(f"Ephemeral message requested but no user_id provided for channel {destination}") response = send_slack_request_sync(CLIENT, post_method, body=body, bot_name=bot_name) return response def send_file(destinations: list, file_dict: dict, integration_context: dict, thread_id: str) -> Optional[SlackResponse]: """ Sends a file to Slack. Args: destinations: Destinations to send the file to. file_dict: The file to send. integration_context: The current integration context. thread_id: A Slack thread to send to. Returns: The Slack send response. """ try: response = send_file_to_destinations(destinations, file_dict, thread_id) except SlackApiError as e: if str(e).find("not_in_channel") == -1 and str(e).find("channel_not_found") == -1: raise bot_id = integration_context.get("bot_id") if not bot_id: bot_id = get_bot_id() integration_context["bot_id"] = bot_id for dest in destinations: invite_users_to_conversation(dest, [bot_id]) response = send_file_to_destinations(destinations, file_dict, thread_id) return response def send_file_to_destinations(destinations: list, file_dict: dict, thread_id: str) -> Optional[SlackResponse]: """ Sends a file to provided destinations in Slack. Args: destinations: The destinations to send to. file_dict: The file to send. thread_id: A thread ID to send to. Returns: The Slack send response. """ response: Optional[SlackResponse] = None for destination in destinations: file_upload_params = FileUploadParams( filename=file_dict["name"], file=file_dict["path"], initial_comment=file_dict.get("comment"), channel=destination, thread_ts=thread_id, ) response = send_slack_request_sync(CLIENT, file_upload_params=file_upload_params) return response def slack_send_request( to: str = None, channel: str = None, group: str = None, entry: str = "", ignore_add_url: bool = False, thread_id: str = "", message: str = "", blocks: str = "", file_dict: dict = None, channel_id: str = None, ) -> Optional[SlackResponse]: """ Requests to send a message or a file to Slack. Args: to: A Slack user to send to. channel: A Slack channel to send to. group: A Slack private channel to send to. entry: WarRoom entry to send. ignore_add_url: Do not add a Demisto URL to the message. thread_id: The Slack thread ID to send to. message: A message to send. blocks: Blocks to send with a slack message file_dict: A file to send. channel_id: ID of channel to send to. Returns: The Slack send response. """ integration_context = get_integration_context(SYNC_CONTEXT) mirrors: list = [] if integration_context and "mirrors" in integration_context: mirrors = json.loads(integration_context["mirrors"]) destinations = [] if to: if isinstance(to, list): to = to[0] user = get_user_by_name(to) if not user: demisto.error(f"Could not find the Slack user {to}") else: body = {"users": user.get("id")} im = send_slack_request_sync(CLIENT, "conversations.open", body=body) destinations.append(im.get("channel", {}).get("id")) # type: ignore[call-overload] if channel or group or channel_id: if channel_id: destinations.append(channel_id) if not destinations: destination_name = channel or group mirrored_channel_filter = list(filter(lambda m: f'incident-{m["investigation_id"]}' == destination_name, mirrors)) if mirrored_channel_filter: channel_mirror = mirrored_channel_filter[0] conversation_id = channel_mirror["channel_id"] else: conversation = get_conversation_by_name(destination_name) # type: ignore if not conversation: return_error( f"Could not find the Slack conversation {destination_name}. If caching is disabled," f" try searching by channel_id" ) conversation_id = conversation.get("id") if conversation_id: destinations.append(conversation_id) if not destinations: return_error("Could not find any destination to send to.") if file_dict: response = send_file(destinations, file_dict, integration_context, thread_id) return response response = send_message(destinations, entry, ignore_add_url, integration_context, message, thread_id, blocks) return response def set_channel_topic(): """ Sets a topic for a slack channel """ channel = demisto.args().get("channel") channel_id = demisto.args().get("channel_id", "") topic = demisto.args().get("topic") if not channel: mirror = find_mirror_by_investigation() if mirror: channel_id = mirror.get("channel_id", "") # We need to update the topic in the mirror integration_context = get_integration_context(SYNC_CONTEXT) mirrors = json.loads(integration_context["mirrors"]) mirror = mirrors.pop(mirrors.index(mirror)) mirror["channel_topic"] = topic mirrors.append(mirror) set_to_integration_context_with_retries({"mirrors": mirrors}, OBJECTS_TO_KEYS, SYNC_CONTEXT) if channel and not channel_id: channel = get_conversation_by_name(channel) channel_id = channel_id if channel_id else channel.get("id") if not channel_id: return_error(CHANNEL_NOT_FOUND_ERROR_MSG) body = {"channel": channel_id, "topic": topic} send_slack_request_sync(CLIENT, "conversations.setTopic", body=body) demisto.results("Topic successfully set.") def rename_channel(): """ Renames a slack channel """ channel = demisto.args().get("channel") channel_id = demisto.args().get("channel_id", "") new_name = demisto.args().get("name") if not channel: mirror = find_mirror_by_investigation() if mirror: channel_id = mirror.get("channel_id", "") # We need to update the name in the mirror integration_context = get_integration_context(SYNC_CONTEXT) mirrors = json.loads(integration_context["mirrors"]) mirror = mirrors.pop(mirrors.index(mirror)) mirror["channel_name"] = new_name mirrors.append(mirror) set_to_integration_context_with_retries({"mirrors": mirrors}, OBJECTS_TO_KEYS, SYNC_CONTEXT) if channel and not channel_id: channel = get_conversation_by_name(channel) channel_id = channel_id if channel_id else channel.get("id") if not channel_id: return_error(CHANNEL_NOT_FOUND_ERROR_MSG) body = {"channel": channel_id, "name": new_name} send_slack_request_sync(CLIENT, "conversations.rename", body=body) demisto.results("Channel renamed successfully.") def close_channel(): """ Archives a slack channel by name or its incident ID if mirrored. """ channel = demisto.args().get("channel") channel_id = demisto.args().get("channel_id", "") mirror = find_mirror_by_investigation() integration_context = get_integration_context(SYNC_CONTEXT) if mirror: channel_id = mirror.get("channel_id", "") # We need to update the topic in the mirror mirrors = json.loads(integration_context["mirrors"]) channel_id = mirror["channel_id"] # Check for mirrors on the archived channel channel_mirrors = list(filter(lambda m: channel_id == m["channel_id"], mirrors)) for mirror in channel_mirrors: mirror["remove"] = True demisto.mirrorInvestigation(mirror["investigation_id"], f'none:{mirror["mirror_direction"]}', mirror["auto_close"]) set_to_integration_context_with_retries({"mirrors": mirrors}, OBJECTS_TO_KEYS, SYNC_CONTEXT) if channel and not channel_id: channel = get_conversation_by_name(channel) channel_id = channel_id if channel_id else channel.get("id") if not channel_id: return_error(CHANNEL_NOT_FOUND_ERROR_MSG) body = {"channel": channel_id} send_slack_request_sync(CLIENT, "conversations.archive", body=body) remove_channel_from_context(channel_id=channel_id, integration_context=integration_context) demisto.results("Channel successfully archived.") def remove_channel_from_context(channel_id: str, integration_context: dict): """ :param channel_id: The channel_id to remove. :param integration_context: The integration_context object. Removes a channel from the integration context """ if "conversations" in integration_context: conversations = json.loads(integration_context["conversations"]) updated_conversations = [conversation for conversation in conversations if conversation.get("id") != channel_id] set_to_integration_context_with_retries({"conversations": updated_conversations}, OBJECTS_TO_KEYS, SYNC_CONTEXT) demisto.debug("Channel successfully removed from context.") demisto.debug("Channel was not stored in the context. No need to delete.") def create_channel(): """ Creates a channel in Slack using the provided arguments. """ channel_type = demisto.args().get("type", "private") channel_name = demisto.args()["name"] users = argToList(demisto.args().get("users", [])) topic = demisto.args().get("topic") body = {"name": channel_name} if channel_type == "private": body["is_private"] = True conversation = send_slack_request_sync(CLIENT, "conversations.create", body=body).get( # type: ignore "channel", {} ) if users: slack_users = search_slack_users(users) invite_users_to_conversation( conversation.get("id"), # type: ignore [u.get("id") for u in slack_users], ) if topic: body = {"channel": conversation.get("id"), "topic": topic} send_slack_request_sync(CLIENT, "conversations.setTopic", body=body) created_channel_name = conversation.get("name") created_channel_id = conversation.get("id") hr = f"Successfully created the channel {created_channel_name}" conversation_context = {"ID": created_channel_id, "Name": created_channel_name} if not DISABLE_CACHING: # Save it to integration context since we have it save_conversation_to_context({"name": created_channel_name, "id": created_channel_id}) context = {"Slack.Channel(val.ID === obj.ID)": conversation_context} return_results(CommandResults(readable_output=hr, outputs=context, raw_response=json.dumps(conversation))) def invite_to_channel(): channel = demisto.args().get("channel") channel_id = demisto.args().get("channel_id", "") users = argToList(demisto.args().get("users", "[]").rstrip(", ")) if not users: # Not raising an error here to preserve BC demisto.results("Missing required argument - users") if not channel: mirror = find_mirror_by_investigation() if mirror: channel_id = mirror["channel_id"] if channel and not channel_id: channel = get_conversation_by_name(channel) channel_id = channel.get("id") if not channel_id: return_error(CHANNEL_NOT_FOUND_ERROR_MSG) slack_users = search_slack_users(users) if slack_users: invite_users_to_conversation(channel_id, [u.get("id") for u in slack_users]) else: return_error("No users found") demisto.results("Successfully invited users to the channel.") def kick_from_channel(): channel = demisto.args().get("channel") channel_id = demisto.args().get("channel_id", "") users = argToList(demisto.args().get("users", [])) if not channel: mirror = find_mirror_by_investigation() if mirror: channel_id = mirror["channel_id"] if channel and not channel_id: channel = get_conversation_by_name(channel) channel_id = channel.get("id") if not channel_id: return_error(CHANNEL_NOT_FOUND_ERROR_MSG) slack_users = search_slack_users(users) if slack_users: kick_users_from_conversation(channel_id, [u.get("id") for u in slack_users]) else: return_error("No users were found") demisto.results("Successfully kicked users from the channel.") def get_user(): user_input = demisto.args()["user"] # Check if the input might be an email or a user ID if re.match(emailRegex, user_input): slack_user = get_user_by_email(user_input) elif re.match(r"^[UW](?=.*\d)[A-Z0-9]{7,10}$", user_input): slack_user = get_user_by_id(user_input) else: slack_user = get_user_by_name(user_input) if not slack_user: err_str = format_user_not_found_error(user=user_input) demisto.results({"Type": WARNING_ENTRY_TYPE, "Contents": err_str, "ContentsFormat": formats["text"]}) profile = slack_user.get("profile", {}) result_user = { "ID": slack_user.get("id"), "Username": slack_user.get("name"), "Name": profile.get("real_name_normalized") or profile.get("real_name"), "DisplayName": profile.get("display_name"), "Email": profile.get("email"), "StatusText": profile.get("status_text"), } hr = tableToMarkdown( "Details for Slack user: " + user_input, result_user, headers=["ID", "Username", "Name", "DisplayName", "Email", "StatusText"], headerTransform=pascalToSpace, removeNull=True, ) context = {"Slack.User(val.ID === obj.ID)": createContext(result_user, removeNull=True)} return_outputs(hr, context, slack_user) def slack_edit_message(): args = demisto.args() channel = args.get("channel") channel_id = args.get("channel_id", "") thread_id = demisto.args().get("threadID") message = args.get("message") blocks = args.get("blocks") ignore_add_url = args.get("ignore_add_url") entry = args.get("entry") if not channel: mirror = find_mirror_by_investigation() channel_id = mirror["channel_id"] if mirror else channel_id if channel_id else channel.get("id") if channel and not channel_id: channel = get_conversation_by_name(channel) channel_id = channel.get("id") if not channel_id: return_error(CHANNEL_NOT_FOUND_ERROR_MSG) if not thread_id: return_error("The timestamp of the message to edit is required.") if message and not blocks: if ignore_add_url and isinstance(ignore_add_url, str): ignore_add_url = argToBoolean(ignore_add_url) if not ignore_add_url: investigation = demisto.investigation() server_links = demisto.demistoUrls() if investigation: if investigation.get("type") != PLAYGROUND_INVESTIGATION_TYPE: link = server_links.get("warRoom") if link: if entry: link += "/" + entry message += f"\nView it on: {link}" else: link = server_links.get("server", "") if link: message += f"\nView it on: {link}#/home" body = {"channel": channel_id, "ts": thread_id} if message: clean_message = handle_tags_in_message_sync(message) body["text"] = clean_message if blocks: if isinstance(blocks, str): body["blocks"] = json.loads(blocks, strict=False) else: body["blocks"] = blocks try: response = send_slack_request_sync(CLIENT, "chat.update", body=body) hr = "The message was successfully edited." result_edit = { "ID": response.get("ts", None), "Channel": response.get("channel", None), "Text": response.get("text", None), } context = {"Slack.Thread(val.ID === obj.ID)": result_edit} return_results(CommandResults(readable_output=hr, outputs=context, raw_response=json.dumps(response.data))) except SlackApiError as slack_error: return_error(f"{slack_error}") def pin_message(): channel = demisto.args().get("channel") thread_id = demisto.args().get("threadID") channel_id = demisto.args().get("channel_id") if not channel: mirror = find_mirror_by_investigation() channel_id = mirror["channel_id"] if mirror else channel_id if channel_id else channel.get("id") if channel and not channel_id: channel = get_conversation_by_name(channel) channel_id = channel.get("id") if not channel_id: return_error(CHANNEL_NOT_FOUND_ERROR_MSG) body = {"channel": channel_id, "timestamp": thread_id} try: send_slack_request_sync(CLIENT, "pins.add", body=body) return_results("The message was successfully pinned.") except SlackApiError as slack_error: return_error(f"{slack_error}") def list_channels(): """ List the conversations in the workspace """ args = demisto.args() # Default for the SDK is public channels, but users can specify "public_channel", "private_channel", "mpim", and "im" # Multiple values can be passed for this argument as a comma separated list # By default archived channels are NOT included by the SDK. Explicitly set this if not set from the CLI or set to False body = { "types": args.get("channel_types"), "exclude_archived": argToBoolean(args.get("exclude_archived", "true")), "limit": args.get("limit"), } if args.get("cursor"): body["cursor"] = args.get("cursor") raw_response = send_slack_request_sync(CLIENT, "conversations.list", http_verb="GET", body=body) # Provide an option to only select a channel by a name. Instead of returning a full list of results this allows granularity # Supports a single channel name if name_filter := args.get("name_filter"): for channel in raw_response["channels"]: if channel["name"] == name_filter: channels = [channel] break else: raise DemistoException(f"No channel found with name: {name_filter}") else: channels = raw_response["channels"] # Force list for consistent parsing if isinstance(channels, dict): channels = [channels] context = [] # type: List for channel in channels: entry = { "ID": channel.get("id"), "Name": channel.get("name"), "Created": channel.get("created"), "Purpose": channel.get("purpose", {}).get("value"), } if channel.get("creator"): creator_details_response = send_slack_request_sync( CLIENT, "users.info", http_verb="GET", body={"user": channel.get("creator")} ) entry["Creator"] = creator_details_response["user"]["name"] context.append(entry) readable_output = tableToMarkdown(f'Channels list for {args.get("channel_types")} with filter {name_filter}', context) demisto.results( { "Type": entryTypes["note"], "Contents": channels, "EntryContext": {"Slack.Channels": context}, "ContentsFormat": formats["json"], "HumanReadable": readable_output, "ReadableContentsFormat": formats["markdown"], } ) def to_unix_seconds_str(time: str) -> str: """ Converts a time string to Unix timestamp in seconds. Args: s (str): The time string to convert. Can be a numeric Unix seconds string, or a human-readable/ISO format date string. Returns: str: Unix timestamp as a string. Raises: ValueError: If the time string cannot be parsed. """ parsed_datetime = dateparser.parse( time, settings={ "TIMEZONE": "UTC", "RETURN_AS_TIMEZONE_AWARE": True, "TO_TIMEZONE": "UTC", "PREFER_DAY_OF_MONTH": "first", "DATE_ORDER": "YMD", }, ) if parsed_datetime is None: raise ValueError( f"Could not parse time string: {time}. " "Expected either a numeric Unix timestamp (seconds) or a human-readable date string " "(e.g., '2023-01-01', 'yesterday', '2023-01-01T12:00:00Z')." ) return f"{parsed_datetime.timestamp()}" def get_direct_message_channel_id_by_username(username): """ Gets the direct message channel ID for a given username. Args: username (str): The Slack username to get the DM channel ID for. Returns: str or None: The channel ID of the direct message conversation with the user, or None if the conversation doesn't exist or an error occurs. """ user_info = get_user_by_name(username) if not user_info or not user_info.get("id"): return None user_id = user_info["id"] raw_response = send_slack_request_sync( CLIENT, "conversations.open", http_verb="POST", body={"users": user_id, "prevent_creation": True} ) return raw_response["channel"].get("id") def resolve_conversation_id_from_name(channel_name): """ Resolves a channel ID from a given channel name. This function attempts to find the channel ID by first checking if the channel_name corresponds to a username for a direct message channel. If that fails, it tries to find a channel with the given name. Args: channel_name (str): The name of the channel or username to resolve. Returns: str: The channel ID corresponding to the given channel name. Raises: DemistoException: If no channel ID could be found for the given channel name. """ # Try to get channel id in case channel_name is user name if (channel_id := get_direct_message_channel_id_by_username(channel_name)) is None: demisto.debug("Did not find conversation ID by username, attempting to find by channel name") conversation_info = get_conversation_by_name(channel_name) channel_id = conversation_info.get("id") if not channel_id: raise CortexResourceNotFoundError( resource_type="Slack channel", identifier=channel_name, override_message=f"Channel '{channel_name}' does not exist.", ) return channel_id def conversation_history() -> None: """ Fetches a conversation's history of messages and events. Raises: ValueError: If neither conversation_id nor conversation_name is provided. DemistoException: If the Slack API returns an error. """ args = demisto.args() conversation_id = args.get("channel_id") or args.get("conversation_id") conversation_name = args.get("conversation_name") limit = arg_to_number(args.get("limit")) from_time = args.get("from_time") page_token = args.get("page_token") thread_id = args.get("thread_id") if not conversation_id and not conversation_name: raise CortexMissingArgError( ["conversation_id", "conversation_name"], require_one=True, override_message="Either conversation_id or conversation_name must be provided.", ) if not conversation_id: conversation_id = resolve_conversation_id_from_name(conversation_name) # When thread_id is provided, delegate to conversation replies logic if thread_id: conversation_replies(channel_id=conversation_id, thread_timestamp=thread_id) return body = {"channel": conversation_id, "limit": limit} if from_time: body["oldest"] = to_unix_seconds_str(from_time) if page_token: body["cursor"] = page_token try: raw_response = send_slack_request_sync(CLIENT, "conversations.history", http_verb="GET", body=body) except SlackApiError as e: raise CortexExternalApiError( override_message=f"An error occurred while listing conversation history: {e.response.get('error')}" ) messages: Any = raw_response.get("messages", []) response_metadata: dict = raw_response.get("response_metadata", {}) or {} cursor: str = response_metadata.get("next_cursor", "") # Normalize messages to list if isinstance(messages, dict): messages = [messages] if not isinstance(messages, list): raise CortexExternalApiError( override_message=f"An error occurred while listing conversation history: {raw_response.get('error')}" ) context: List[Dict[str, Any]] = [] for message in messages: thread_ts = "N/A" has_replies = "No" name = "N/A" full_name = "N/A" if "subtype" not in message: user_id = message.get("user") user_details_response = send_slack_request_sync(CLIENT, "users.info", http_verb="GET", body={"user": user_id}) user_details: dict = user_details_response.get("user", {}) or {} full_name = user_details.get("real_name", "N/A") name = user_details.get("name", "N/A") if "thread_ts" in message: thread_ts = message.get("thread_ts") has_replies = "Yes" elif "thread_ts" in message: thread_ts = message.get("thread_ts") has_replies = "Yes" full_name = message.get("username") name = message.get("username") entry = { "Type": message.get("type"), "Text": message.get("text"), "UserId": message.get("user"), "Name": name, "FullName": full_name, "TimeStamp": message.get("ts"), "HasReplies": has_replies, "ThreadTimeStamp": thread_ts, } context.append(entry) readable_output = tableToMarkdown(f"Channel details from Channel ID - {conversation_id}", context) results = [ CommandResults( outputs_prefix="Slack.Messages", outputs=context, readable_output=readable_output, raw_response=messages, ) ] # Add pagination token if present if cursor: results.append( CommandResults( outputs_prefix="SlackConversationHistory", outputs_key_field="NextPageToken", outputs={"NextPageToken": cursor}, ) ) return_results(results) def conversation_replies( channel_id: str | None = None, thread_timestamp: str | None = None, ): """ Retrieves replies to specific messages, regardless of whether it's from a public or private channel, direct message, or otherwise. Args: channel_id: Optional channel ID override. Falls back to demisto.args(). thread_timestamp: Optional thread timestamp override. Falls back to demisto.args(). """ args = demisto.args() channel_id = channel_id or args.get("channel_id") thread_timestamp = thread_timestamp or args.get("thread_timestamp") context: list = [] readable_output: str = "" body = {"channel": channel_id, "ts": thread_timestamp, "limit": arg_to_number(args.get("limit"))} raw_response = send_slack_request_sync(CLIENT, "conversations.replies", http_verb="GET", body=body) messages = raw_response.get("messages", "") if not raw_response.get("ok"): error = raw_response.get("error") return_error(f"An error occurred while listing conversation replies: {error}") if isinstance(messages, dict): messages = [messages] if not isinstance(messages, list): raise DemistoException(f'An error occurred while listing conversation replies: {raw_response.get("error")}') for message in messages: reply_count = "No" name = "N/A" full_name = "N/A" if "subtype" not in message: user_id = message.get("user") body = {"user": user_id} user_details_response = send_slack_request_sync(CLIENT, "users.info", http_verb="GET", body=body) user_details = user_details_response.get("user") name = user_details.get("name") full_name = user_details.get("real_name") if "reply_count" in message: reply_count = "Yes" entry = { "Type": message.get("type"), "Text": message.get("text"), "UserId": message.get("user"), "Name": name, "FullName": full_name, "TimeStamp": message.get("ts"), "ThreadTimeStamp": message.get("thread_ts"), "IsParent": reply_count, } context.append(entry) readable_output = tableToMarkdown(f"Channel details from Channel ID - {channel_id}", context) demisto.results( { "Type": entryTypes["note"], "Contents": messages, "EntryContext": {"Slack.Threads": context}, "ContentsFormat": formats["json"], "HumanReadable": readable_output, "ReadableContentsFormat": formats["markdown"], } ) def long_running_main(): """ Starts the long running thread. """ try: asyncio.run(start_listening(), debug=EXTENSIVE_LOGGING) except Exception as e: demisto.error(f"The Loop has failed to run {e!s}") finally: loop = asyncio.get_running_loop() try: loop.stop() loop.close() except Exception as e_: demisto.error(f"Failed to gracefully close the loop - {e_}") def init_globals(command_name: str = ""): """ Initializes global variables according to the integration parameters """ global \ BOT_TOKEN, \ PROXY_URL, \ PROXIES, \ DEDICATED_CHANNEL, \ CLIENT, \ USER_CLIENT, \ CACHED_INTEGRATION_CONTEXT, \ MIRRORING_ENABLED, \ FILE_MIRRORING_ENABLED, \ USER_TOKEN global SEVERITY_THRESHOLD, ALLOW_INCIDENTS, INCIDENT_TYPE, VERIFY_CERT, ENABLE_DM, BOT_ID, CACHE_EXPIRY global BOT_NAME, BOT_ICON_URL, MAX_LIMIT_TIME, PAGINATED_COUNT, SSL_CONTEXT, APP_TOKEN, ASYNC_CLIENT global DEFAULT_PERMITTED_NOTIFICATION_TYPES, CUSTOM_PERMITTED_NOTIFICATION_TYPES, PERMITTED_NOTIFICATION_TYPES global COMMON_CHANNELS, DISABLE_CACHING, CHANNEL_NOT_FOUND_ERROR_MSG, LONG_RUNNING_ENABLED, DEMISTO_API_KEY, DEMISTO_URL global IGNORE_RETRIES, EXTENSIVE_LOGGING, ENABLED_AI_ASSISTANT params: dict[str, Any] = demisto.params() VERIFY_CERT = not params.get("unsecure", False) if not VERIFY_CERT: SSL_CONTEXT = ssl.create_default_context() SSL_CONTEXT.check_hostname = False SSL_CONTEXT.verify_mode = ssl.CERT_NONE else: # Use default SSL context SSL_CONTEXT = None BOT_TOKEN = params.get("bot_token", {}).get("password", "") APP_TOKEN = params.get("app_token", {}).get("password", "") USER_TOKEN = params.get("user_token", {}).get("password", "") PROXIES = handle_proxy() PROXY_URL = PROXIES.get("http") # aiohttp only supports http proxy DEDICATED_CHANNEL = params.get("incidentNotificationChannel") or "" ASYNC_CLIENT = AsyncWebClient(token=BOT_TOKEN, ssl=SSL_CONTEXT, proxy=PROXY_URL) CLIENT = slack_sdk.WebClient(token=BOT_TOKEN, proxy=PROXY_URL, ssl=SSL_CONTEXT) USER_CLIENT = slack_sdk.WebClient(token=USER_TOKEN, proxy=PROXY_URL, ssl=SSL_CONTEXT) SEVERITY_THRESHOLD = SEVERITY_DICT.get(params.get("min_severity", "Low"), 1) ALLOW_INCIDENTS = params.get("allow_incidents", False) INCIDENT_TYPE = params.get("incidentType") or "" BOT_NAME = params.get("bot_name") or "" # Bot default name defined by the slack plugin (3-rd party) BOT_ICON_URL = params.get("bot_icon") or "" # Bot default icon url defined by the slack plugin (3-rd party) MAX_LIMIT_TIME = int(params.get("max_limit_time", "60")) PAGINATED_COUNT = int(params.get("paginated_count", "200")) ENABLE_DM = params.get("enable_dm", True) DEFAULT_PERMITTED_NOTIFICATION_TYPES = ["externalAskSubmit", "externalFormSubmit"] CUSTOM_PERMITTED_NOTIFICATION_TYPES = params.get("permitted_notifications", []) PERMITTED_NOTIFICATION_TYPES = DEFAULT_PERMITTED_NOTIFICATION_TYPES + CUSTOM_PERMITTED_NOTIFICATION_TYPES MIRRORING_ENABLED = params.get("mirroring", True) FILE_MIRRORING_ENABLED = params.get("enable_outbound_file_mirroring", False) LONG_RUNNING_ENABLED = params.get("longRunning", True) DEMISTO_API_KEY = params.get("demisto_api_key", {}).get("password", "") demisto_urls = demisto.demistoUrls() DEMISTO_URL = demisto_urls.get("server", "") IGNORE_RETRIES = params.get("ignore_event_retries", True) EXTENSIVE_LOGGING = params.get("extensive_logging", False) common_channels = params.get("common_channels", "") COMMON_CHANNELS = parse_common_channels(common_channels) DISABLE_CACHING = params.get("disable_caching", False) ENABLED_AI_ASSISTANT = params.get("enabled_ai_assistant", True) # Formats the error message for the 'Channel Not Found' errors error_str = "The channel was not found" if DISABLE_CACHING: error_str += ( " and Disable Caching of Users and Channels is checked. While caching is disabled, please use the" " `channel_id` argument, or configure" " the Common Channels parameter. If this command worked for you previously consider enabling " "caching. However, note that it is recommended to Disable Caching. Please refer to " "https://xsoar.pan.dev/docs/reference/integrations/slack-v3#caching for more details." ) else: error_str += ( ". Either the Slack app is not a member of the channel, or the slack app does not have permission" " to find the channel." ) CHANNEL_NOT_FOUND_ERROR_MSG = error_str if command_name != "long-running-execution": loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) if not loop._default_executor: # type: ignore[attr-defined] demisto.info(f"setting _default_executor on loop: {loop} id: {id(loop)}") loop.set_default_executor(concurrent.futures.ThreadPoolExecutor(max_workers=4)) # Handle Long-Running Specific Globals if command_name == "long-running-execution": demisto.debug("in long running execution init globals") # Bot identification integration_context = get_integration_context(SYNC_CONTEXT) if integration_context.get("bot_user_id"): BOT_ID = integration_context["bot_user_id"] if BOT_ID == "null" or BOT_ID is None: # In some cases the bot_id can be stored as a string 'null', this handles this edge case. BOT_ID = get_bot_id() set_to_integration_context_with_retries({"bot_user_id": BOT_ID}, OBJECTS_TO_KEYS, SYNC_CONTEXT) else: BOT_ID = get_bot_id() set_to_integration_context_with_retries({"bot_user_id": BOT_ID}, OBJECTS_TO_KEYS, SYNC_CONTEXT) # Pull initial Cached context and set the Expiry CACHE_EXPIRY = next_expiry_time() CACHED_INTEGRATION_CONTEXT = get_integration_context(SYNC_CONTEXT) def parse_common_channels(common_channels: str): common_channels = (common_channels or "").strip() if not common_channels: return {} try: stripped_channels = {} for pair in re.split(r",|\n", common_channels): stripped = pair.strip() if stripped: key, val = stripped.split(":") stripped_channels[key.strip()] = val.strip() except Exception as e: demisto.error(f"{common_channels=} error parsing common channels {e!s}") raise ValueError( "Invalid common_channels parameter value. common_channels must be in key:value,key2:value2 format" ) from e return stripped_channels def print_thread_dump(): demisto.info(f"current thread: {threading.current_thread().name}") for threadId, stack in sys._current_frames().items(): stack_str = "\n".join(traceback.format_stack(stack)) demisto.info(f"{threadId} stack: {stack_str}") def loop_info(): loop = asyncio.get_running_loop() info = f"loop: {loop}. id: {id(loop)}." info += f"executor: {loop._default_executor} id: {id(loop._default_executor)}" # type: ignore[attr-defined] if loop._default_executor: # type: ignore[attr-defined] info += f" executor threads size: {len(loop._default_executor._threads)}" # type: ignore[attr-defined] info += f" max: {loop._default_executor._max_workers} {loop._default_executor._threads}" # type: ignore[attr-defined] return info def slack_get_integration_context(): context_statistics, integration_context = slack_get_integration_context_statistics() readable_stats = tableToMarkdown(name="Long Running Context Statistics", t=context_statistics) demisto.results( { "Type": entryTypes["note"], "HumanReadable": readable_stats, "ContentsFormat": EntryFormat.MARKDOWN, "Contents": readable_stats, } ) return_results(fileResult("slack_integration_context.json", json.dumps(integration_context), EntryType.ENTRY_INFO_FILE)) def slack_get_integration_context_statistics(): context_statistics = {} integration_context = get_integration_context() # Mirrors Data if integration_context.get("mirrors"): mirrors = json.loads(integration_context.get("mirrors")) context_statistics["Mirrors Count"] = len(mirrors) context_statistics["Mirror Size In Bytes"] = sys.getsizeof(integration_context.get("mirrors", [])) # Conversations Data if integration_context.get("conversations"): conversations = json.loads(integration_context.get("conversations")) context_statistics["Conversations Count"] = len(conversations) context_statistics["Conversations Size In Bytes"] = sys.getsizeof(integration_context.get("conversations", [])) # Users Data if integration_context.get("users"): users = json.loads(integration_context.get("users")) context_statistics["Users Count"] = len(users) context_statistics["Users Size In Bytes"] = sys.getsizeof(integration_context.get("users", [])) # Questions Data if integration_context.get("questions"): questions = json.loads(integration_context.get("questions")) context_statistics["Questions Count"] = len(questions) context_statistics["Questions Size In Bytes"] = sys.getsizeof(integration_context.get("questions", [])) return context_statistics, integration_context def user_session_reset(): user_id = demisto.args().get("user_id") body = { "user_id": user_id, } try: send_slack_request_sync(USER_CLIENT, "admin.users.session.reset", body=body) return_results(CommandResults(readable_output=f"The session was reset successfully to the user {user_id}.")) except SlackApiError as slack_error: return_error(f"{slack_error}") def fetch_samples(): """ The integration fetches incidents in the long-running-execution command. Fetch incidents is called only when "Pull From Instance" is clicked in create new classifier section in Cortex XSOAR. The fetch incidents returns samples of incidents generated by the long-running-execution. """ demisto.incidents(get_integration_context().get("samples")) def main() -> None: """ Main """ global CLIENT, USER_CLIENT, EXTENSIVE_LOGGING commands = { "test-module": test_module, "fetch-incidents": fetch_samples, "long-running-execution": long_running_main, "mirror-investigation": mirror_investigation, "send-notification": slack_send, "slack-send-notification-quick-action": slack_send, "slack-send-file": slack_send_file, "slack-set-channel-topic": set_channel_topic, "close-channel": close_channel, "slack-create-channel": create_channel, "slack-invite-to-channel": invite_to_channel, "slack-kick-from-channel": kick_from_channel, "slack-rename-channel": rename_channel, "slack-get-user-details": get_user, "slack-get-integration-context": slack_get_integration_context, "slack-edit-message": slack_edit_message, "slack-pin-message": pin_message, "slack-user-session-reset": user_session_reset, "slack-get-conversation-history": conversation_history, "slack-list-channels": list_channels, "slack-get-conversation-replies": conversation_replies, "send-agent-response": send_agent_response, } command_name: str = demisto.command() try: demisto.info(f"{command_name} started.") command_func = commands[command_name] init_globals(command_name) demisto.info("after init globals") if EXTENSIVE_LOGGING: os.environ["PYTHONASYNCIODEBUG"] = "1" support_multithreading() command_func() # type: ignore except Exception as e: demisto.error(f"Error occured error: {e}") demisto.error(traceback.format_exc()) return_error(str(e)) finally: demisto.info(f"{command_name} completed.") # type: ignore if EXTENSIVE_LOGGING: print_thread_dump() """ ENTRY POINT """ if __name__ in ("__main__", "__builtin__", "builtins"): register_signal_handler_profiling_dump(profiling_dump_rows_limit=PROFILING_DUMP_ROWS_LIMIT) main()