diff --git a/Packs/ApiModules/Scripts/ZoomApiModule/ZoomApiModule.py b/Packs/ApiModules/Scripts/ZoomApiModule/ZoomApiModule.py
index 63cb3f5589d8..ae03dbad55d3 100644
--- a/Packs/ApiModules/Scripts/ZoomApiModule/ZoomApiModule.py
+++ b/Packs/ApiModules/Scripts/ZoomApiModule/ZoomApiModule.py
@@ -1,5 +1,4 @@
import demistomock as demisto # noqa: F401
-import jwt
from CommonServerPython import * # noqa: F401
from datetime import timedelta
import dateparser
@@ -20,7 +19,8 @@
INVALID_CREDENTIALS = 'Invalid credentials. Please verify that your credentials are valid.'
INVALID_API_SECRET = 'Invalid API Secret. Please verify that your API Secret is valid.'
INVALID_ID_OR_SECRET = 'Invalid Client ID or Client Secret. Please verify that your ID and Secret is valid.'
-
+INVALID_TOKEN = 'Invalid Authorization token. Please verify that your Bot ID and Bot Secret is valid.'
+INVALID_BOT_ID = 'No Chatbot can be found with the given robot_jid value. Please verify that your Bot JID is correct'
'''CLIENT CLASS'''
@@ -35,8 +35,11 @@ def __init__(
account_id: str | None = None,
client_id: str | None = None,
client_secret: str | None = None,
+ bot_client_id: str | None = None,
+ bot_client_secret: str | None = None,
verify=True,
proxy=False,
+ bot_jid: str | None = None,
):
super().__init__(base_url, verify, proxy)
self.api_key = api_key
@@ -44,30 +47,40 @@ def __init__(
self.account_id = account_id
self.client_id = client_id
self.client_secret = client_secret
- is_jwt = (api_key and api_secret) and not (client_id and client_secret and account_id)
- if is_jwt:
- # the user has chosen to use the JWT authentication method (deprecated)
- self.access_token: str | None = get_jwt_token(api_key, api_secret) # type: ignore[arg-type]
- else:
- # the user has chosen to use the OAUTH authentication method.
- try:
- self.access_token = self.get_oauth_token()
- except Exception as e:
- demisto.debug(f"Cannot get access token. Error: {e}")
- self.access_token = None
+ self.bot_client_id = bot_client_id
+ self.bot_client_secret = bot_client_secret
+ self.bot_jid = bot_jid
+ try:
+ self.access_token, self.bot_access_token = self.get_oauth_token()
+ except Exception as e:
+ demisto.info(f"Cannot get access token. Error: {e}")
+ self.access_token = None
+ self.bot_access_token = None
def generate_oauth_token(self):
"""
- Generate an OAuth Access token using the app credentials (AKA: client id and client secret) and the account id
+ Generate an OAuth Access token using the app credentials (AKA: client id and client secret) and the account id
- :return: valid token
- """
+ :return: valid token
+ """
token_res = self._http_request(method="POST", full_url=OAUTH_TOKEN_GENERATOR_URL,
params={"account_id": self.account_id,
"grant_type": "account_credentials"},
auth=(self.client_id, self.client_secret))
return token_res.get('access_token')
+ def generate_oauth_client_token(self):
+ """
+ Generate an OAuth Access token using the app credentials (AKA: client id and client secret) and the account id
+
+ :return: valid token
+ """
+ token_res = self._http_request(method="POST", full_url=OAUTH_TOKEN_GENERATOR_URL,
+ params={"account_id": self.account_id,
+ "grant_type": "client_credentials"},
+ auth=(self.bot_client_id, self.bot_client_secret))
+ return token_res.get('access_token')
+
def get_oauth_token(self, force_gen_new_token=False):
"""
Retrieves the token from the server if it's expired and updates the global HEADERS to include it
@@ -79,10 +92,15 @@ def get_oauth_token(self, force_gen_new_token=False):
"""
now = datetime.now()
ctx = get_integration_context()
+ client_oauth_token = None
+ oauth_token = None
if not ctx or not ctx.get('token_info').get('generation_time', force_gen_new_token):
# new token is needed
- oauth_token = self.generate_oauth_token()
+ if self.client_id and self.client_secret:
+ oauth_token = self.generate_oauth_token()
+ if self.bot_client_id and self.bot_client_secret:
+ client_oauth_token = self.generate_oauth_client_token()
ctx = {}
else:
if generation_time := dateparser.parse(
@@ -93,14 +111,19 @@ def get_oauth_token(self, force_gen_new_token=False):
time_passed = TOKEN_LIFE_TIME
if time_passed < TOKEN_LIFE_TIME:
# token hasn't expired
- return ctx.get('token_info').get('oauth_token')
+ return ctx.get('token_info', {}).get('oauth_token'), ctx.get('token_info', {}).get('client_oauth_token')
else:
# token expired
- oauth_token = self.generate_oauth_token()
-
- ctx.update({'token_info': {'oauth_token': oauth_token, 'generation_time': now.strftime("%Y-%m-%dT%H:%M:%S")}})
+ # new token is needed
+ if self.client_id and self.client_secret:
+ oauth_token = self.generate_oauth_token()
+ if self.bot_client_id and self.bot_client_secret:
+ client_oauth_token = self.generate_oauth_client_token()
+
+ ctx.update({'token_info': {'oauth_token': oauth_token, 'client_oauth_token': client_oauth_token,
+ 'generation_time': now.strftime("%Y-%m-%dT%H:%M:%S")}})
set_integration_context(ctx)
- return oauth_token
+ return oauth_token, client_oauth_token
def error_handled_http_request(self, method, url_suffix='', full_url=None, headers=None,
auth=None, json_data=None, params=None, files=None, data=None,
@@ -115,30 +138,21 @@ def error_handled_http_request(self, method, url_suffix='', full_url=None, heade
auth=auth, json_data=json_data, params=params, files=files, data=data,
return_empty_response=return_empty_response, resp_type=resp_type, stream=stream)
except DemistoException as e:
- if ('Invalid access token' in e.message
- or "Access token is expired." in e.message):
- self.access_token = self.generate_oauth_token()
- headers = {'authorization': f'Bearer {self.access_token}'}
+ if any(message in e.message for message in ["Invalid access token",
+ "Access token is expired.",
+ "Invalid authorization token"]):
+ if url_suffix == '/im/chat/messages':
+ demisto.debug('generate new bot client token')
+ self.bot_access_token = self.generate_oauth_client_token()
+ headers = {'authorization': f'Bearer {self.bot_access_token}'}
+ else:
+ self.access_token = self.generate_oauth_token()
+ headers = {'authorization': f'Bearer {self.access_token}'}
return super()._http_request(method=method, url_suffix=url_suffix, full_url=full_url, headers=headers,
auth=auth, json_data=json_data, params=params, files=files, data=data,
return_empty_response=return_empty_response, resp_type=resp_type, stream=stream)
else:
- raise DemistoException(e.message)
+ raise DemistoException(e.message, url_suffix)
''' HELPER FUNCTIONS '''
-
-
-def get_jwt_token(apiKey: str, apiSecret: str) -> str:
- """
- Encode the JWT token given the api ket and secret
- """
- now = datetime.now()
- expire_time = int(now.strftime('%s')) + JWT_LIFETIME
- payload = {
- 'iss': apiKey,
-
- 'exp': expire_time
- }
- encoded = jwt.encode(payload, apiSecret, algorithm='HS256')
- return encoded
diff --git a/Packs/ApiModules/Scripts/ZoomApiModule/ZoomApiModule_test.py b/Packs/ApiModules/Scripts/ZoomApiModule/ZoomApiModule_test.py
index 7f410c2b0d66..10ff26375eef 100644
--- a/Packs/ApiModules/Scripts/ZoomApiModule/ZoomApiModule_test.py
+++ b/Packs/ApiModules/Scripts/ZoomApiModule/ZoomApiModule_test.py
@@ -144,19 +144,3 @@ def test_http_request___when_raising_invalid_token_message(mocker):
pass
assert m.call_count == 2
assert generate_token_mock.called
-
-
-@freeze_time("1988-03-03T11:00:00")
-def test_get_jwt_token__encoding_format_check():
- """
- Given -
-
- When -
- creating a jwt token
- Then -
- Validate that the token is in the right format
- """
- import ZoomApiModule
- encoded_token = ZoomApiModule.get_jwt_token(apiKey="blabla", apiSecret="blabla")
- # 124 is the expected token length based on parameters given
- assert len(encoded_token) == 124
diff --git a/Packs/Zoom/.pack-ignore b/Packs/Zoom/.pack-ignore
index c23acbe2730c..be24cd2a8a86 100644
--- a/Packs/Zoom/.pack-ignore
+++ b/Packs/Zoom/.pack-ignore
@@ -6,6 +6,7 @@ ignore=IM111
[known_words]
zoomapimodule
+JWT
[file:Zoom.yml]
ignore=MR108
diff --git a/Packs/Zoom/Integrations/Zoom/README.md b/Packs/Zoom/Integrations/Zoom/README.md
index 0d4f61c7da23..76deb5968a61 100644
--- a/Packs/Zoom/Integrations/Zoom/README.md
+++ b/Packs/Zoom/Integrations/Zoom/README.md
@@ -9,19 +9,99 @@ This integration was integrated and tested with version 2.0.0 of Zoom
| **Parameter** | **Description** | **Required** |
| --- | --- | --- |
- | Server URL (e.g. '') | | False |
- | Account ID (OAuth) | | False |
- | Client ID (OAuth) | | False |
- | Client Secret (OAuth) | | False |
- | API Key (JWT-Deprecated.) | This authentication method will be deprecated by Zoom in June 2023. | False |
- | API Secret (JWT-Deprecated.) | This authentication method will be deprecated by Zoom in June 2023. | False |
- | API Key (JWT-Deprecated.) | This authentication method will be deprecated by Zoom in June 2023. | False |
- | API Secret (JWT-Deprecated.) | This authentication method will be deprecated by Zoom in June 2023. | False |
- | Use system proxy settings | | False |
- | Trust any certificate (not secure) | | False |
+ | `Server URL` (e.g., '') | | True |
+ | `Account ID (OAuth)` | | True |
+ | `Client ID (OAuth)` | | True |
+ | `Client Secret (OAuth)` | | True |
+ | `Use system proxy settings` | | False |
+ | `Trust any certificate (not secure)` | | False |
+ | `Long running instance`| Enable in order to use zoom-ask and for mirroring. |False |
+ | `Listen Port`|Listener port number. |False|
+ | `Bot JID`| Zoom Bot app JID. | False|
+ | `Bot Client ID (OAuth)`| Zoom Bot app client ID. | False|
+ | `Bot Client Secret (OAuth)`| Zoom Bot app secret ID. | False|
+ | `Secret Token`| For mirroring, see [Configuring Secret Token](#secret-token). |False|
+ | `Verification Token`| For verify the mirror in. |False|
+ | `Mirroring` | Enable Incident Mirroring. See [how to configure the app](#secret-token). | False |
+ | `Certificate (Required for HTTPS)`| (For Cortex XSOAR 6.x) For use with HTTPS - the certificate that the service should use. (For Cortex XSOAR 8 and Cortex XSIAM) Custom certificates are supported only using engine.|False|
+ |`Private Key (Required for HTTPS)`|(For Cortex XSOAR 6.x) For use with HTTPS - the private key that the service should use. (For Cortex XSOAR 8 and Cortex XSIAM) When using an engine, configure a private API key|False|
+
+
+
+
4. Click **Test** to validate the URLs, token, and connection.
+
+### Server configuration (XSOAR 6.x)
+
+In the Server Configuration section, verify that the value for the instance.execute.external.`INTEGRATION-INSTANCE-NAME` key is set to true. If this key does not exist:
+1. Click **+ Add Server Configuration**.
+2. Add **instance.execute.external.`INTEGRATION-INSTANCE-NAME`** and set the value to **true**.
+
+XSOAR endpoint URL-
+ - **For Cortex XSOAR 6.x**: `/instance/execute/`. For example, `https://my.demisto.live/instance/execute/zoom`. Note that the string `instance` does not refer to the name of your XSOAR instance, but rather is part of the URL.
+ - **For Cortex XSOAR 8.x / XSIAM**: you need to run using external engine: `https://:`. For example, https://my-engine-url:7001.
+
+
+## Create Zoom ChatBOT app
+1. Navigate to https://marketplace.zoom.us/.
+2. Click **Develop** > **Build Team** > **Team Chat Apps**.
+3. Enter the **App Name**.
+4. Click **Create**.
+
+![enter image description here](doc_files/create-team-chat-app.gif)
+### Configure App Settings
+Enter your Cortex XSOAR endpoint URL in all Redirect URLS.
+
+
+1. Click **Feature**> **Team Chat**.
+In the Team Chat Subscription section under BOT endpoint URL add:
+ - For Cortex XSOAR 6.x: `/instance/execute/`. For example, `https://my.demisto.live/instance/execute/zoom`. Note that the string `instance` does not refer to the name of your Cortex XSOAR instance, but rather is part of the URL.
+ - For Cortex XSOAR 8.x / XSAIM you need to run using extrnal engine: `https://:`. For example, https://my-engine-url:7001.
+
+
+![enter image description here](doc_files/bot_endpoint_url.gif)
+
+1. Click **Scopes** > **+ Add Scopes** to add the following scope permissions.
+ | Scope Type | Scope Name |
+ | --- | --- |
+ | Team Chat | Enable Chatbot within Zoom Team Chat Client /imchat:bot |
+ | Team Chat | Send a team chat message to a Zoom Team Chat user or channel on behalf of a Chatbot /imchat:write:admin |
+ | Team Chat | View and manage all users' team chat channels /chat_channel:write:admin |
+ | User | View all user information /user:read:admin |
+![enter image description here](doc_files/scope-premissions.png)
+
+1. Click **Local Test** >**Add** to test your app and authorize your Cortex XSOAR app.
+ ![enter image description here](doc_files/test-zoom-app.gif)
+
+ 1. **If mirroring is enabled in the integration configuration or using ZoomAsk**:
+**Endpoint URL Requirements-**
+ To receive webhooks, the Event notification endpoint URL that you specify for each event subscription must:
+ * Be a publicly accessible https endpoint url that supports TLSv1.2+ with a valid certificate chain issued by a Certificate Authority (CA).
+ * Be able to accept HTTP POST requests.
+ * Be able to respond with a 200 or 204 HTTP Status Code.
+
+ 1. Copy the **secret token** from the "Feature" page under the "Token" section and add it
+ to the instance configuration.
+ ![enter image description here](doc_files/zoom-token.png)
+ 2. Configure Event Subscriptions.
+ 1. In the "Feature" page
+ under the "General Features" section, enable "Event Subscriptions".
+ 2. Click **+Add New Event Subscription**.
+ 3. Enter the following information:
+ - Subscription name: Enter a name for this Event Subscription (e.g., "Send Message Sent").
+ - Authentication Header Option -
+ 1. **Default Header Provided by Zoom option**- This option allows you to use a verification token provided by Zoom. Copy the **verification token** from the "Feature" page under the "Token" section and add it to the instance configuration.
+ ![enter image description here](doc_files/verification.png)
+ 2. **Basic Authentication Option (must in XSOAR8)** you can use Basic Authentication by providing your Zoom Client ID (OAuth) and Secret ID (OAuth) as configured in the instance configuration.
+ ![enter image description here](doc_files/authentication_header.png)
+ - Event notification endpoint URL: Enter the Cortex XSOAR URL of your server (`CORTEX-XSOAR-URL`/instance/execute/`INTEGRATION-INSTANCE-NAME`) where you want to receive event notifications. This URL should handle incoming event data from Zoom. Make sure it's publicly accessible.
+ - Validate the URL: Just after setting up/configuration of the Cortex XSOAR side you can validate the URL.
+ - Add Events: Click **+Add Events**. Under Event types, select **Chat Message** and then select **Chat message sent**.
+![enter image description here](doc_files/add-event.gif)
+
+
## Commands
You can execute these commands from the Cortex XSOAR CLI, as part of an automation, or in a playbook.
@@ -1838,3 +1918,100 @@ Searches chat messages or shared files between a user and an individual contact
>| 2023-05-22T08:24:14Z | None | a62636c8-b6c1-4135-9352-88ac61eafc31 | | message | admin zoom | None | uJiZN-O7Rp6Jp_995FpZGg |
>| 2023-05-22T08:20:22Z | None | 4a59df4a-9668-46bd-bff2-3e1f3462ecc3 | | my message | admin zoom | None | uJiZN-O7Rp6Jp_995FpZGg |
+### send-notification
+
+***
+Sends messages from your Marketplace Chatbot app on Zoom to either an individual user or to a channel.
+
+#### Base Command
+
+`send-notification`
+
+#### Input
+
+| **Argument Name** | **Description** | **Required** |
+| --- | --- | --- |
+| to | The email address or user ID or member ID of the person to send a message. | Required |
+| channel_id | The channel ID of the channel to send a message. | Optional |
+| message | The message to be sent. Maximum of 1024 characters. | Required |
+| visible_to_user | The UserID that allows a Chatbot to send a message to a group channel when it has only one designated person in that group channel to see the message. | Optional |
+| zoom_ask | Whether to send the message as a JSON. | Optional |
+
+#### Context Output
+
+There is no context output for this command.
+
+#### Command example
+
+```!send-notification message=hi to=example@example.com```
+
+#### Context Output
+
+There is no context output for this command.
+
+#### Human Readable Output
+
+>### Message
+>Message sent to Zoom successfully. Message ID is: 20230815153245201_BPK3S3S_aw1
+
+
+## mirror-investigation
+
+***
+Mirrors the investigation between Zoom 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 |
+| channelName | The name of the channel. The default is "incident-<incidentID>". | Optional |
+
+#### Context Output
+
+There is no context output for this command.
+
+#### Command Example
+
+```!mirror-investigation direction="FromDemisto" channelName="example"```
+
+#### Human Readable Output
+
+> Investigation mirrored successfully, channel:example
+
+### close-channel
+
+***
+Delete a mirrored Zoom channel.
+
+#### Base Command
+
+`close-channel`
+
+#### Input
+
+| **Argument Name** | **Description** | **Required** |
+| --- | --- | --- |
+| channel | The name of the channel to delete. If not provided, the mirrored investigation channel is deleted (if the channel exists). | Optional |
+| channel_id | The ID of the channel to delete. If not provided, the mirrored investigation channel is deleted (if the channel exists). | Optional |
+
+#### Context Output
+
+There is no context output for this command.
+
+#### Command Example
+
+```
+!close-channel channel=new-zoom-channel
+```
+
+#### Human Readable Output
+
+> Channel successfully deleted.
+
diff --git a/Packs/Zoom/Integrations/Zoom/Zoom.py b/Packs/Zoom/Integrations/Zoom/Zoom.py
index 9638462294ea..4e2551fe029c 100644
--- a/Packs/Zoom/Integrations/Zoom/Zoom.py
+++ b/Packs/Zoom/Integrations/Zoom/Zoom.py
@@ -4,7 +4,31 @@
from ZoomApiModule import *
from traceback import format_exc
from datetime import datetime
-
+from fastapi import Depends, FastAPI, Request, Response, status
+from fastapi.responses import JSONResponse
+from fastapi.security import HTTPBasic, HTTPBasicCredentials
+from fastapi.security.api_key import APIKey, APIKeyHeader
+from secrets import compare_digest
+from fastapi_utils.tasks import repeat_every
+import uvicorn
+from uvicorn.logging import AccessFormatter
+from copy import copy
+import hashlib
+import hmac
+from tempfile import NamedTemporaryFile
+
+
+app = FastAPI()
+
+basic_auth = HTTPBasic(auto_error=False)
+token_auth = APIKeyHeader(auto_error=False, name='Authorization')
+
+SYNC_CONTEXT = True
+OBJECTS_TO_KEYS = {
+ 'messages': 'entitlement',
+}
+DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
+PLAYGROUND_INVESTIGATION_TYPE = 9
# Note#1: type "Pro" is the old version, and "Licensed" is the new one, and i want to support both.
# Note#2: type "Corporate" is officially not supported any more, but i did not remove it just in case it still works.
@@ -84,11 +108,54 @@
rt_start_position, rt_end_position or format_attr"""
MARKDOWN_EXTRA_FORMATS = """Too many style in text. you can provide only one style type"""
MARKDOWN_EXTRA_MENTIONS = """Too many mentions in text. you can provide only one mention in each message"""
+WRONG_CHANNEL = """Couldn't find channel id base on provided channel_name. channel_name can use only for mirrored channel.
+Otherwise, please use the channel ID instead."""
+MISSING_ARGUMENT_JID = """Missing argument: You must provide either a user ID or a channel ID.
+If you're using a mirrored channel, you have the option to specify the channel name as well."""
+TOO_MANY_JID = """Too many argument you must provide either a user JID or a channel id and not both """
+BOT_PARAM_CHECK = """If you're using the Zoom chatbot, it's essential to provide all the necessary bot parameters,
+including the botJID, bot_client_id, and bot_client_secret.
+you can find these values in the Zoom Chatbot app configuration"""
+OAUTH_PARAM_CHECK = """if you are using zoom APIs you must provide Oauth client_id and client_secret
+you can find this values in the Zoom Oauth Server-To-Server app configuration"""
+CLIENT: Zoom_Client
+SECRET_TOKEN: str
+MESSAGE_FOOTER = '\n**From Zoom**'
+MIRRORING_ENABLED: bool = False
+LONG_RUNNING: bool = False
+MIRROR_TYPE = 'mirrorEntry'
+CACHED_INTEGRATION_CONTEXT: dict
+CACHE_EXPIRY: float
'''CLIENT CLASS'''
+class UserAgentFormatter(AccessFormatter):
+ """This formatter extracts and includes the 'User-Agent' header information
+ in the log messages."""
+
+ def get_user_agent(self, scope: Dict) -> str:
+ headers = scope.get('headers', [])
+ user_agent_header = list(filter(lambda header: header[0].decode().lower() == 'user-agent', headers))
+ user_agent = ''
+ if len(user_agent_header) == 1:
+ user_agent = user_agent_header[0][1].decode()
+ return user_agent
+
+ def format_message(self, record):
+ """Include the 'User-Agent' header information in the log message.
+ Args:
+ record: The log record to be formatted.
+ Returns:
+ str: The formatted log message."""
+ record_copy = copy(record)
+ scope = record_copy.__dict__['scope']
+ user_agent = self.get_user_agent(scope)
+ record_copy.__dict__.update({'user_agent': user_agent})
+ return super().formatMessage(record_copy)
+
+
class Client(Zoom_Client):
""" A client class that implements logic to authenticate with Zoom application. """
@@ -313,17 +380,446 @@ def zoom_list_user_messages(self, user_id: str, date_arg: datetime, from_arg: da
'exclude_child_message': exclude_child_message}
)
+ def zoom_send_notification(self, url_suffix: str, json_data: dict):
+ return self.error_handled_http_request(
+ method='POST',
+ url_suffix=url_suffix,
+ json_data=json_data,
+ headers={'authorization': f'Bearer {self.bot_access_token}'}
+ )
+
+ def zoom_get_admin_user_id_from_token(self):
+ return self.error_handled_http_request(
+ method='get',
+ url_suffix='users/me',
+ headers={'authorization': f'Bearer {self.access_token}'}
+ )
+
'''HELPER FUNCTIONS'''
+def next_expiry_time() -> float:
+ """
+ Returns:
+ A float representation of a new expiry time with an offset of 5 seconds
+ """
+ return (datetime.now(timezone.utc) + timedelta(seconds=5)).timestamp()
+
+
+async def check_and_handle_entitlement(text: str, message_id: str, user_name: str) -> str:
+ """
+ Handles an entitlement message (a reply to a question)
+ Args:
+ Returns:
+ If the message contains entitlement, return a reply.
+ """
+ integration_context = fetch_context(force_refresh=True)
+ messages = integration_context.get('messages', [])
+ reply = ''
+ if not messages or not message_id:
+ return reply
+ messages = json.loads(messages)
+ message_filter = list(filter(lambda q: q.get('message_id') == message_id, messages))
+ if message_filter:
+ message = message_filter[0]
+ entitlement = message.get('entitlement')
+ reply = message.get('reply', f'Thank you {user_name} for your response {text}.')
+ guid, incident_id, task_id = extract_entitlement(entitlement)
+ demisto.handleEntitlementForUser(incident_id, guid, user_name, text, task_id)
+ message['remove'] = True
+ set_to_integration_context_with_retries({'messages': messages}, OBJECTS_TO_KEYS, SYNC_CONTEXT)
+ return reply
+
+
+def save_entitlement(entitlement, message_id, reply, expiry, default_response, to_jid):
+ """
+ Saves an entitlement
+
+ Args:
+ entitlement: The entitlement
+ message_id: The message_id
+ reply: The reply to send to the user.
+ expiry: The question expiration date.
+ default_response: The response to send if the question times out.
+ to_jid: the user jid the message was sent to
+ """
+ integration_context = get_integration_context(SYNC_CONTEXT)
+ messages = integration_context.get('messages', [])
+ if messages:
+ messages = json.loads(integration_context['messages'])
+ messages.append({
+ 'message_id': message_id,
+ 'entitlement': entitlement,
+ 'reply': reply,
+ 'expiry': expiry,
+ 'sent': datetime.strftime(datetime.utcnow(), DATE_FORMAT),
+ 'default_response': default_response,
+ 'to_jid': to_jid
+ })
+
+ set_to_integration_context_with_retries({'messages': messages}, OBJECTS_TO_KEYS, SYNC_CONTEXT)
+
+
+def extract_entitlement(entitlement: str) -> tuple[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('@')
+ if len(parts) < 2:
+ raise DemistoException("Entitlement cannot be parsed")
+ 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]
+
+ return guid, incident_id, task_id
+
+
+@app.on_event("startup")
+@repeat_every(seconds=60, wait_first=True)
+async def check_for_unanswered_messages():
+ # demisto.debug('check for unanswered messages')
+ integration_context = fetch_context()
+ messages = integration_context.get('messages')
+ if messages:
+ messages = json.loads(messages)
+ now = datetime.utcnow()
+ updated_messages = []
+
+ for message in messages:
+ if message.get('expiry'):
+ # Check if the question expired - if it did, answer it with the default response
+ # and remove it
+ expiry = datetime.strptime(message['expiry'], DATE_FORMAT)
+ if expiry < now:
+ demisto.debug(f"message expired: {message}")
+ _ = await answer_question(message.get('default_response'), message, email='')
+ updated_messages.append(message)
+ continue
+ updated_messages.append(message)
+ if updated_messages:
+ set_to_integration_context_with_retries({'messages': messages}, OBJECTS_TO_KEYS, SYNC_CONTEXT)
+
+
+def run_long_running(port: int, is_test: bool = False):
+ while True:
+ certificate = demisto.params().get('certificate', '')
+ private_key = demisto.params().get('key', '')
+
+ certificate_path = ''
+ private_key_path = ''
+ try:
+ ssl_args = {}
+
+ if certificate and private_key:
+ certificate_file = NamedTemporaryFile(delete=False)
+ certificate_path = certificate_file.name
+ certificate_file.write(bytes(certificate, 'utf-8'))
+ certificate_file.close()
+ ssl_args['ssl_certfile'] = certificate_path
+
+ private_key_file = NamedTemporaryFile(delete=False)
+ private_key_path = private_key_file.name
+ private_key_file.write(bytes(private_key, 'utf-8'))
+ private_key_file.close()
+ ssl_args['ssl_keyfile'] = private_key_path
+
+ demisto.debug('Starting HTTPS Server')
+ else:
+ demisto.debug('Starting HTTP Server')
+
+ integration_logger = IntegrationLogger()
+ integration_logger.buffering = False
+ log_config = dict(uvicorn.config.LOGGING_CONFIG)
+ log_config['handlers']['default']['stream'] = integration_logger
+ log_config['handlers']['access']['stream'] = integration_logger
+ log_config['formatters']['access'] = {
+ '()': UserAgentFormatter,
+ 'fmt': '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s "%(user_agent)s"'
+ }
+ uvicorn.run(app, host='0.0.0.0', port=port, log_config=log_config, **ssl_args)
+ except Exception as e:
+ demisto.error(f'An error occurred in the long running loop: {str(e)} - {format_exc()}')
+ demisto.updateModuleHealth(f'An error occurred: {str(e)}')
+ finally:
+ if certificate_path:
+ os.unlink(certificate_path)
+ if private_key_path:
+ os.unlink(private_key_path)
+ time.sleep(5)
+
+
+def run_log_running(port: int, is_test: bool = False):
+ while True:
+ try:
+ demisto.debug('Starting Server')
+ integration_logger = IntegrationLogger()
+ integration_logger.buffering = False
+ log_config = dict(uvicorn.config.LOGGING_CONFIG)
+ log_config['handlers']['default']['stream'] = integration_logger
+ log_config['handlers']['access']['stream'] = integration_logger
+ log_config['formatters']['access'] = {
+ '()': UserAgentFormatter,
+ 'fmt': '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s "%(user_agent)s"'
+ }
+ uvicorn.run(app, host='0.0.0.0', port=port, log_config=log_config)
+ if is_test:
+ time.sleep(5)
+ return 'ok'
+ except Exception as e:
+ demisto.error(f'An error occurred in the long running loop: {str(e)} - {format_exc()}')
+ demisto.updateModuleHealth(f'An error occurred: {str(e)}')
+ finally:
+ time.sleep(5)
+
+
+async def zoom_send_notification_async(client, url_suffix, json_data_all):
+ client.zoom_send_notification(url_suffix, json_data_all)
+
+
+async def process_entitlement_reply(
+ entitlement_reply: str,
+ account_id: str,
+ robot_jid: str,
+ to_jid: str | None = None,
+ user_name: str | None = None,
+ action_text: 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_name: str: name of the user who answered the entitlement
+ :param action_text: str: The text attached to the button, used for string replacement.
+ :param toJid: str: The Jid of where the question exists.
+ :param accountId: str: Zoom account ID
+ :param robotJid: str: Zoom BOT JID
+ :return: None
+ """
+ if '{user}' in entitlement_reply:
+ entitlement_reply = entitlement_reply.replace('{user}', str(user_name))
+ if '{response}' in entitlement_reply and action_text:
+ entitlement_reply = entitlement_reply.replace('{response}', str(action_text))
+
+ url_suffix = '/im/chat/messages'
+ content_json = {
+ "content": {
+ "body": [
+ {
+ "type": "message",
+ "text": entitlement_reply
+ }
+ ]
+ },
+ "to_jid": to_jid,
+ "robot_jid": robot_jid,
+ "account_id": account_id
+ }
+ await zoom_send_notification_async(CLIENT, url_suffix, content_json)
+
+
+async def answer_question(text: str, question: dict, email: str = ''):
+ entitlement = question.get('entitlement', '')
+ to_jid = question.get('to_jid')
+ guid, incident_id, task_id = extract_entitlement(entitlement)
+ try:
+ demisto.handleEntitlementForUser(incident_id, guid, email, text, task_id)
+ account_id = CLIENT.account_id
+ bot_jid = CLIENT.bot_jid
+ if account_id and bot_jid:
+ _ = await process_entitlement_reply(text, account_id, bot_jid, to_jid)
+ except Exception as e:
+ demisto.error(f'Failed handling entitlement {entitlement}: {str(e)}')
+ question['remove'] = True
+ return incident_id
+
+
+async def handle_text_received_from_zoom(investigation_id: str, text: str, operator_email: str, operator_name: str):
+ """
+ Handles text received from Zoom
+
+ Args:
+ investigation_id: The mirrored investigation ID
+ text: The received text
+ operator_email: The sender email
+ operator_name: The sender name
+ """
+ if text:
+ demisto.addEntry(id=investigation_id,
+ entry=text,
+ username=operator_name,
+ email=operator_email,
+ footer=MESSAGE_FOOTER
+ )
+
+
+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 handle_mirroring(payload):
+ """
+ handle messages from the Zoom webhook that have been identified as possible mirrored messages
+ 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 payload: str: The request payload from zoom
+ :return: None
+ """
+ channel_id = payload.get("object", {}).get("channel_id")
+ if not channel_id:
+ return
+ 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_direction'] and mirror['mirror_type']:
+ investigation_id = mirror['investigation_id']
+ mirror_type = mirror['mirror_type']
+ auto_close = mirror['auto_close']
+ direction = mirror['mirror_direction']
+ 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)
+ message = payload["object"]["message"]
+ demisto.info(f"payload:{payload}")
+ operator_email = payload["operator"]
+ operator_name = zoom_get_user_name_by_email(CLIENT, operator_email)
+ investigation_id = mirror['investigation_id']
+ await handle_text_received_from_zoom(investigation_id, message, operator_email, operator_name)
+
+
+async def event_url_validation(payload):
+ """ Verify the authenticity of a token
+ Args:
+ payload : request from Zoom
+ Returns:
+ json_res: A dictionary containing the 'plainToken' and its corresponding 'encryptedToken' (HMAC-SHA256 signature).
+ """
+ plaintoken = payload.get('plainToken')
+ hash_object = hmac.new(SECRET_TOKEN.encode('utf-8'), msg=plaintoken.encode('utf-8'), digestmod=hashlib.sha256)
+ expected_signature = hash_object.hexdigest()
+ json_res = {
+ "plainToken": plaintoken,
+ "encryptedToken": expected_signature
+ }
+ return json_res
+
+
+@app.post('/')
+async def handle_zoom_response(request: Request, credentials: HTTPBasicCredentials = Depends(basic_auth),
+ token: APIKey = Depends(token_auth)):
+ """handle any response that came from Zoom app
+ Args:
+ request : zoom request
+ Returns:
+ JSONResponse:response to zoom
+ """
+ request = await request.json()
+ demisto.debug(request)
+ credentials_param = demisto.params().get('credentials')
+ auth_failed = False
+ v_token = demisto.params().get('verification_token', {}).get('password')
+ if not str(token).startswith('Basic') and v_token:
+ if token != v_token:
+ auth_failed = True
+
+ elif credentials and credentials_param and (username := credentials_param.get('identifier')):
+ password = credentials_param.get('password', '')
+ if not compare_digest(credentials.username, username) or not compare_digest(credentials.password, password):
+ auth_failed = True
+ if auth_failed:
+ demisto.debug('Authorization failed')
+ return Response(status_code=status.HTTP_401_UNAUTHORIZED, content='Authorization failed.')
+
+ event_type = request['event']
+ payload = request['payload']
+ try:
+ if event_type == 'endpoint.url_validation' and SECRET_TOKEN:
+ res = await event_url_validation(payload)
+ return JSONResponse(content=res)
+
+ elif event_type == 'interactive_message_actions':
+ if 'actionItem' in payload:
+ action = payload['actionItem']['value']
+ elif 'selectedItems' in payload:
+ action = payload['selectedItems'][0]['value']
+ else:
+ return Response(status_code=status.HTTP_400_BAD_REQUEST)
+ message_id = payload['messageId']
+ account_id = payload['accountId']
+ robot_jid = payload['robotJid']
+ to_jid = payload['toJid']
+ user_name = payload['userName']
+ entitlement_reply = await check_and_handle_entitlement(action, message_id, user_name)
+ if entitlement_reply:
+ await process_entitlement_reply(entitlement_reply, account_id, robot_jid, to_jid, user_name, action)
+ demisto.updateModuleHealth("")
+ demisto.debug(f"Action {action} was clicked on message id {message_id}")
+ return Response(status_code=status.HTTP_200_OK)
+ elif event_type == "chat_message.sent" and MIRRORING_ENABLED:
+ await handle_mirroring(payload)
+ return Response(status_code=status.HTTP_200_OK)
+ else:
+ return Response(status_code=status.HTTP_400_BAD_REQUEST)
+ except Exception as e:
+ await handle_listen_error(f'An error occurred while handling a response from Zoom: {e}')
+
+
def test_module(client: Client):
"""Tests connectivity with the client.
Takes as an argument all client arguments to create a new client
"""
try:
- # running an arbitrary command to test the connection
+ if MIRRORING_ENABLED and (not LONG_RUNNING or not SECRET_TOKEN or not client.bot_client_id or not
+ client.bot_client_secret or not client.bot_jid):
+ raise DemistoException("""Mirroring is enabled, however long running is disabled
+or the necessary bot authentication parameters are missing.
+For mirrors to work correctly, long running must be enabled and you must provide all
+the zoom-bot following parameters:
+secret token,
+Bot JID,
+bot client id and secret id""")
client.zoom_list_users(page_size=1, url_suffix="users")
+ if client.bot_access_token:
+ json_data = {
+ "robot_jid": client.bot_jid,
+ "to_jid": "foo@conference.xmpp.zoom.us",
+ "account_id": client.account_id,
+ "content": {
+ "head": {
+ "text": "hi"
+ }
+ }
+ }
+ client.zoom_send_notification(url_suffix='/im/chat/messages', json_data=json_data)
except DemistoException as e:
error_message = e.message
if 'Invalid access token' in error_message:
@@ -332,6 +828,14 @@ def test_module(client: Client):
error_message = INVALID_API_SECRET
elif 'Invalid client_id or client_secret' in error_message:
error_message = INVALID_ID_OR_SECRET
+ elif 'No channel or user can be found with the given to_jid.' in error_message:
+ return 'ok'
+ elif 'Invalid authorization token.' in error_message:
+ error_message = INVALID_TOKEN
+ elif 'No Chatbot can be found with the given robot_jid value.' in error_message:
+ error_message = INVALID_BOT_ID
+ elif 'Invalid robot_jid value specified in the Request Body.' in error_message:
+ error_message = INVALID_BOT_ID
else:
error_message = f'Problem reaching Zoom API, check your credentials. Error message: {error_message}'
return error_message
@@ -833,6 +1337,22 @@ def check_authentication_type_parameters(api_key: str, api_secret: str,
raise DemistoException(EXTRA_PARAMS)
+def check_authentication_bot_parameters(bot_Jid: str, client_id: str, client_secret: str):
+ """check authentication parameters that both client_id, secret_id, bot_Jid are provided or none of them"""
+ if (bot_Jid and client_id and client_secret) or (not bot_Jid and not client_id and not client_secret):
+ return
+ else:
+ raise DemistoException(BOT_PARAM_CHECK)
+
+
+def check_authentication_parameters(client_id: str, client_secret: str):
+ """check authentication parameters that both client_id and secret are provided"""
+ if (client_id and client_secret) or (not client_id and not client_secret):
+ return
+ else:
+ raise DemistoException(OAUTH_PARAM_CHECK)
+
+
def zoom_list_account_public_channels_command(client, **args) -> CommandResults:
"""
Lists public channels associated with a Zoom account.
@@ -1504,6 +2024,25 @@ def zoom_get_user_id_by_email(client, email):
return user_id
+def zoom_get_user_name_by_email(client, user_email):
+ """
+ Retrieves the user name associated with the given email address.
+
+ :param client: The Zoom client object.
+ user_email: The email address of the user.
+
+ :return: The user name associated with the email address.
+ :rtype: str
+ """
+ user_url_suffix = f'users/{user_email}'
+ user_name = client.zoom_list_users(page_size=1, url_suffix=user_url_suffix)
+ demisto.info(f"user_name: {user_name}")
+ if not user_name:
+ raise DemistoException(USER_NOT_FOUND)
+ user_name = user_name.get('display_name')
+ return user_name
+
+
def zoom_list_messages_command(client, **args) -> CommandResults:
"""
Lists messages from Zoom chat.
@@ -1590,11 +2129,9 @@ def zoom_list_messages_command(client, **args) -> CommandResults:
error_message = e.message
if 'The next page token is invalid or expired.' in error_message and next_page_token:
raise DemistoException(f"Please ensure that the correct argument values are used when attempting to use \
- the next_page_toke.\n \
- Note that when using next_page_token it is mandatory to specify date time and not \
- relative time.\n \
- To find the appropriate values, refer to the ChatMessageNextToken located in the context. \
- \n {error_message}")
+the next_page_toke.\n Note that when using next_page_token it is mandatory to specify date time and not relative time.\n \
+To find the appropriate values, refer to the ChatMessageNextToken located in the context. \n {error_message}")
+
outputs = []
for i in all_messages:
outputs.append({
@@ -1624,35 +2161,349 @@ def zoom_list_messages_command(client, **args) -> CommandResults:
)
+def get_channel_jid_from_context(channel_name: str = None, investigation_id=None):
+ """
+ Retrieves a Zoom channel JID based on the provided criteria.
+
+ :param channel_name: The name of the channel to get the JID for.
+ :param investigation_id: The Demisto investigation ID to search for a mirrored channel.
+
+ :return: The requested channel JID or None if not found.
+ """
+ if not channel_name and not investigation_id:
+ return None
+ integration_context = fetch_context()
+ mirrors = json.loads(integration_context.get('mirrors', '[]'))
+
+ # Filter mirrors based on the provided criteria.
+ if investigation_id:
+ mirrored_channel_filter = next((m for m in mirrors if m["investigation_id"] == investigation_id), None)
+ else:
+ mirrored_channel_filter = next((m for m in mirrors if m["channel_name"] == channel_name), None)
+ if mirrored_channel_filter:
+ return mirrored_channel_filter.get('channel_jid')
+ return None
+
+
+def send_notification(client, **args):
+
+ client = client
+ bot_jid = client.bot_jid
+ account_id = client.account_id
+ to = args.get('to')
+ channel_id = args.get('channel_id')
+ visible_to_user = args.get('visible_to_user')
+ zoom_ask = argToBoolean(args.get('zoom_ask', False))
+ entitlement = None
+
+ message_type = args.get('messageType', '') # From server
+ original_message = args.get('originalMessage', '') # From server
+ entry_object = args.get('entryObject') # From server
+ channel = args.get('channel') # From server
+ investigation_id = None
+ if entry_object:
+ investigation_id = entry_object.get('investigationId') # From server, available from demisto v6.1 and above
+
+ if message_type and message_type != MIRROR_TYPE:
+ return (f"Message type is not in permitted options. Received: {message_type}")
+
+ if message_type == MIRROR_TYPE and original_message.find(MESSAGE_FOOTER) != -1:
+ # return so there will not be a loop of messages
+ return ("Message already mirrored")
+
+ if to and '@xmpp.zoom.us' not in to:
+
+ if re.match(emailRegex, to):
+ to = zoom_get_user_id_by_email(client, to).lower() + '@xmpp.zoom.us'
+ else:
+ to = to.lower() + '@xmpp.zoom.us'
+
+ if channel_id and '@conference.xmpp.zoom.us' not in channel_id:
+ channel_id = channel_id.lower() + '@conference.xmpp.zoom.us'
+
+ if zoom_ask:
+ parsed_message = json.loads(args.get("message", ''))
+ entitlement = parsed_message.get('entitlement')
+ message = parsed_message.get('blocks')
+ reply = parsed_message.get('reply')
+ expiry = parsed_message.get('expiry')
+ default_response = parsed_message.get('default_response')
+ else:
+ message = {"head": {"type": "message", "text": args.get("message", "")}}
+ if channel: # if channel name provided
+ channel_id = get_channel_jid_from_context(channel, investigation_id)
+ if not channel_id:
+ raise DemistoException(WRONG_CHANNEL)
+ if (to and channel_id):
+ raise DemistoException(TOO_MANY_JID)
+ if not to and not channel_id:
+ raise DemistoException(MISSING_ARGUMENT_JID)
+
+ url_suffix = '/im/chat/messages'
+ json_data_all = {
+ "robot_jid": bot_jid,
+ "to_jid": to if to else channel_id,
+ "account_id": account_id,
+ "visible_to_user": visible_to_user,
+ "content":
+ message
+ }
+ json_data = remove_None_values_from_dict(json_data_all)
+
+ raw_data = client.zoom_send_notification(url_suffix, json_data)
+ if raw_data:
+ message_id = raw_data.get("message_id")
+ if entitlement:
+ save_entitlement(entitlement, message_id, reply, expiry, default_response, to if to else channel_id)
+ return CommandResults(
+ readable_output=f'Message sent to Zoom successfully. Message ID is: {raw_data.get("message_id")}'
+ )
+
+
+def get_admin_user_id_from_token(client):
+ """ get the user admin id from the token"""
+ admin_user_result = client.zoom_get_admin_user_id_from_token()
+ return admin_user_result.get('id')
+
+
+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(timezone.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 mirror_investigation(client, **args) -> CommandResults:
+ if not MIRRORING_ENABLED:
+ demisto.error(" couldn't mirror investigation, Mirroring is disabled")
+ if MIRRORING_ENABLED and not LONG_RUNNING:
+ demisto.error('Mirroring is enabled, however long running is disabled. For mirrors to work correctly,'
+ ' long running must be enabled.')
+ client = client
+ type = args.get('type', 'all')
+ direction = args.get('direction', 'Both')
+ channel_name = args.get('channelName')
+ autoclose = argToBoolean(args.get('autoclose', True))
+ send_first_message = False
+ # kick_admin = argToBoolean(args.get('kickAdmin', False))
+ # members = argToList(args.get('members'))
+
+ investigation = demisto.investigation()
+ investigation_id = str(investigation.get('id'))
+ if investigation.get('type') == PLAYGROUND_INVESTIGATION_TYPE:
+ return_error('Sorry, but this action cannot be performed in the playground ')
+
+ integration_context = get_integration_context(SYNC_CONTEXT)
+ if not integration_context or not integration_context.get('mirrors', []):
+ mirrors: list = []
+ current_mirror = []
+ else:
+ mirrors = json.loads(integration_context['mirrors'])
+ current_mirror = list(filter(lambda m: m['investigation_id'] == investigation_id, mirrors))
+
+ # get admin user id from token
+ admin_user_id = get_admin_user_id_from_token(client)
+
+ channel_filter: list = []
+ if channel_name:
+ # check if channel already exists
+ channel_filter = list(filter(lambda m: m['channel_name'] == channel_name, mirrors))
+
+ if not current_mirror:
+ channel_name = channel_name or f'incident-{investigation_id}'
+
+ if not channel_filter:
+ # create new channel
+ result = zoom_create_channel_command(client=client, user_id=admin_user_id, channel_type='Public channel',
+ channel_name=channel_name, member_emails=admin_user_id)
+ if result and isinstance(result.outputs, dict) and len(result.outputs) > 0:
+ channel_jid = result.outputs.get('jid')
+ channel_id = result.outputs.get('id')
+ else:
+ raise DemistoException("error in create new channel")
+ send_first_message = True
+ else:
+ mirrored_channel = channel_filter[0]
+ channel_jid = mirrored_channel['channel_jid']
+ channel_id = mirrored_channel['channel_id']
+ channel_name = mirrored_channel['channel_name']
+
+ mirror = {
+ 'channel_jid': channel_jid,
+ 'channel_id': channel_id,
+ 'channel_name': channel_name,
+ 'investigation_id': investigation.get('id'),
+ 'mirror_type': type,
+ 'mirror_direction': direction,
+ 'auto_close': bool(autoclose),
+ 'mirrored': True
+ }
+ else:
+ mirror = mirrors.pop(mirrors.index(current_mirror[0]))
+ channel_jid = mirror['channel_jid']
+ channel_id = mirror['channel_id']
+ if type:
+ mirror['mirror_type'] = type
+ if autoclose:
+ mirror['auto_close'] = autoclose
+ if direction:
+ mirror['mirror_direction'] = direction
+ if channel_name and not channel_filter:
+ # update channel name
+ result = zoom_update_channel_command(client=client, user_id=admin_user_id,
+ channel_name=channel_name, channel_id=channel_id)
+ mirror['channel_name'] = channel_name
+ channel_name = mirror['channel_name']
+ mirror['mirrored'] = True
+ demisto.mirrorInvestigation(investigation_id, f'{type}:{direction}', autoclose)
+
+ mirrors.append(mirror)
+ set_to_integration_context_with_retries({'mirrors': mirrors}, 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}')
+ url_suffix = '/im/chat/messages'
+ json_data_all = {
+ "content": {
+ "body": [
+ {
+ "type": "message",
+ "text": message
+ }
+ ]
+ },
+ "to_jid": channel_jid,
+ "robot_jid": client.bot_jid,
+ "account_id": client.account_id
+ }
+ client.zoom_send_notification(url_suffix, json_data_all)
+ # if kick_admin:
+ # demisto.debug("kick-admin:")
+ # url_suffix = f'/chat/users/{admin_user_id}/channels/{channel_id}/members/{admin_user_id}'
+ # res = client.zoom_remove_from_channel(url_suffix)
+ # demisto.debug(f"res: {res}")
+ return CommandResults(
+ readable_output=f'Investigation mirrored successfully,\n channel name:{channel_name} \n channel JID: {channel_jid}'
+ )
+
+
+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 'mirrors' in integration_context:
+ 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 close_channel(client, **args):
+ """
+ if AutoClose is true in mirroring
+ delete the mirrored zoom channel from zoom and from the context
+ """
+ client = client
+ admin_user_id = get_admin_user_id_from_token(client)
+ mirror = find_mirror_by_investigation()
+ channel_id = None
+ integration_context = get_integration_context(SYNC_CONTEXT)
+ if mirror:
+ mirrors = json.loads(integration_context['mirrors'])
+ channel_id = mirror.get('channel_id', '')
+ 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"]}',
+ bool(mirror['auto_close']))
+ set_to_integration_context_with_retries({'mirrors': mirrors}, OBJECTS_TO_KEYS, SYNC_CONTEXT)
+ if channel_id:
+ zoom_delete_channel_command(client=client, channel_id=channel_id, user_id=admin_user_id)
+ return 'Channel successfully deleted.'
+ return return_error('Channel {channel_id} not found')
+
+
def main(): # pragma: no cover
params = demisto.params()
args = demisto.args()
base_url = params.get('url')
- api_key = params.get('creds_api_key', {}).get('password') or params.get('apiKey')
- api_secret = params.get('creds_api_secret', {}).get('password') or params.get('apiSecret')
account_id = params.get('account_id')
client_id = params.get('credentials', {}).get('identifier')
client_secret = params.get('credentials', {}).get('password')
+ bot_client_id = params.get('bot_credentials', {}).get('identifier')
+ bot_client_secret = params.get('bot_credentials', {}).get('password')
verify_certificate = not params.get('insecure', False)
proxy = params.get('proxy', False)
- command = demisto.command()
+ bot_jid = params.get('botJID', None)
+ secret_token = params.get('secret_token', {}).get('password')
+ global SECRET_TOKEN, LONG_RUNNING, MIRRORING_ENABLED, CACHE_EXPIRY, CACHED_INTEGRATION_CONTEXT
+ SECRET_TOKEN = secret_token
+ LONG_RUNNING = params.get('longRunning', False)
+ MIRRORING_ENABLED = params.get('mirroring', False)
+
+ # Pull initial Cached context and set the Expiry
+ CACHE_EXPIRY = next_expiry_time()
+ CACHED_INTEGRATION_CONTEXT = get_integration_context(SYNC_CONTEXT)
+
+ if MIRRORING_ENABLED and (not LONG_RUNNING or not SECRET_TOKEN or not bot_client_id or not bot_client_secret or not bot_jid):
+ raise DemistoException("""Mirroring is enabled, however long running is disabled
+or the necessary bot authentication parameters are missing.
+For mirrors to work correctly, long running must be enabled and you must provide all
+the zoom-bot following parameters:
+secret token,
+Bot JID,
+bot client id and secret id""")
+ if LONG_RUNNING:
+ try:
+ port = int(params.get('longRunningPort'))
+ except ValueError as e:
+ raise ValueError(f'Invalid listen port - {e}')
+ command = demisto.command()
# this is to avoid BC. because some of the arguments given as , i.e "user-list"
args = {key.replace('-', '_'): val for key, val in args.items()}
try:
- check_authentication_type_parameters(api_key, api_secret, account_id, client_id, client_secret)
-
+ check_authentication_bot_parameters(bot_jid, bot_client_id, bot_client_secret)
+ check_authentication_parameters(client_id, client_secret)
+ global CLIENT
client = Client(
base_url=base_url,
verify=verify_certificate,
proxy=proxy,
- api_key=api_key,
- api_secret=api_secret,
+ bot_jid=bot_jid,
account_id=account_id,
client_id=client_id,
client_secret=client_secret,
+ bot_client_id=bot_client_id,
+ bot_client_secret=bot_client_secret,
)
+ CLIENT = client
if command == 'test-module':
return_results(test_module(client=client))
@@ -1660,7 +2511,13 @@ def main(): # pragma: no cover
demisto.debug(f'Command being called is {command}')
'''CRUD commands'''
- if command == 'zoom-create-user':
+ if command == 'long-running-execution':
+ run_long_running(port)
+ elif command == 'mirror-investigation':
+ results = mirror_investigation(client, **args)
+ elif command == 'close-channel':
+ results = close_channel(client, **args)
+ elif command == 'zoom-create-user':
results = zoom_create_user_command(client, **args)
elif command == 'zoom-create-meeting':
results = zoom_create_meeting_command(client, **args)
@@ -1698,6 +2555,9 @@ def main(): # pragma: no cover
results = zoom_delete_message_command(client, **args)
elif command == 'zoom-update-message':
results = zoom_update_message_command(client, **args)
+ elif command == 'send-notification':
+ results = send_notification(client, **args)
+
else:
return_error('Unrecognized command: ' + demisto.command())
return_results(results)
diff --git a/Packs/Zoom/Integrations/Zoom/Zoom.yml b/Packs/Zoom/Integrations/Zoom/Zoom.yml
index 214ec183f67a..4a8273c5c7c7 100644
--- a/Packs/Zoom/Integrations/Zoom/Zoom.yml
+++ b/Packs/Zoom/Integrations/Zoom/Zoom.yml
@@ -7,49 +7,77 @@ configuration:
name: url
type: 0
defaultvalue: "https://api.zoom.us/v2/"
- required: false
+ required: true
- display: Account ID (OAuth)
name: account_id
type: 0
- required: false
+ required: true
- display: Client ID (OAuth)
name: credentials
type: 9
displaypassword: Client Secret (OAuth)
+ required: true
+- display: Bot JID
+ name: botJID
+ type: 0
required: false
-- name: creds_api_key
- displaypassword: API Key (JWT-Deprecated.)
+ hiddenpassword: true
+ additionalinfo: The BotJID in the Chat subscription section on the Features page
+ of your App Dashboard.
+- display: Bot Client ID ((Team Chat App))
+ name: bot_credentials
+ type: 9
+ displaypassword: Bot Client Secret (Team Chat App)
+- name: secret_token
type: 9
- additionalinfo: This authentication method will be deprecated by Zoom in June 2023.
hiddenusername: true
required: false
-- name: creds_api_secret
- displaypassword: API Secret (JWT-Deprecated.)
+ displaypassword: Secret Token(Team Chat App)
+ additionalinfo: You must provide this token for mirror-investigation.
+- name: verification_token
type: 9
- additionalinfo: This authentication method will be deprecated by Zoom in June 2023.
hiddenusername: true
required: false
-- display: API Key (JWT-Deprecated.)
- name: apiKey
- type: 4
- additionalinfo: This authentication method will be deprecated by Zoom in June 2023.
- hidden: true
- required: false
-- display: API Secret (JWT-Deprecated.)
- name: apiSecret
- type: 4
- additionalinfo: This authentication method will be deprecated by Zoom in June 2023.
- hidden: true
+ displaypassword: Verification Token(Team Chat App)
+ additionalinfo: Token to verify the messages from Zoom.
+- display: Long running instance
+ name: longRunning
+ section: Collect
+ type: 8
+- defaultvalue: 'false'
+ display: Enable Incident Mirroring
+ name: mirroring
+ type: 8
+ section: Collect
+ additionalinfo: You most enable or run mirror-investigation.
+- display: Listen Port
+ name: longRunningPort
+ type: 0
+ section: Collect
required: false
+ additionalinfo: You must enable Long running instance to run the web server on this port from within Cortex XSOAR. Requires a unique port for each long-running integration instance. Do not use the same port for multiple instances.
- display: Use system proxy settings
name: proxy
type: 8
- required: false
- display: Trust any certificate (not secure)
name: insecure
type: 8
required: false
-description: Use the Zoom integration manage your Zoom users and meetings.
+- display: Certificate (Required for HTTPS)
+ additionalinfo: (For Cortex XSOAR 6.x) For use with HTTPS - the certificate that the service should use. (For Cortex XSOAR 8 and Cortex XSIAM) Custom certificates are supported only using engine.
+ name: certificate
+ type: 12
+ section: Connect
+ advanced: true
+ required: false
+- display: Private Key (Required for HTTPS)
+ additionalinfo: (For Cortex XSOAR 6.x) For use with HTTPS - the private key that the service should use. (For Cortex XSOAR 8 and Cortex XSIAM) When using an engine, configure a private API key. Not supported on the Cortex XSOAR or Cortex XSIAM server.
+ name: key
+ type: 14
+ section: Connect
+ advanced: true
+ required: false
+description: Use the Zoom integration to manage your Zoom users and meetings.
display: Zoom
name: Zoom
script:
@@ -66,8 +94,7 @@ script:
required: true
- auto: PREDEFINED
defaultValue: Basic
- description: >
- The type of the newly created user. Note: the old type "pro" in now called "Licensed", and the type "Corporate" is not sopprted in Zoom v2 and above.
+ description: 'The type of the newly created user. Note: The old type "pro" is now called "Licensed", and the type "Corporate" is not supprted in Zoom v2 and above .'
name: user_type
predefined:
- Basic
@@ -106,9 +133,9 @@ script:
- Scheduled
- "Recurring meeting with fixed time"
required: true
- - description: >-
- For recurring meetings only. Select the final date on which the meeting will recur before it is canceled
-
+ - description: >
+ For recurring meetings only.
+ Select the final date on which the meeting will recur before it is canceled.
For example: 2017-11-25T12:00:00Z.
name: end_date_time
- description: >
@@ -125,13 +152,13 @@ script:
name: monthly_day
- description: >-
For recurring meetings with Monthly recurrence_type only.
-
- State the week of the month when the meeting should recur. If you use this field, you must also use the monthly_week_day field to state the day of the week when the meeting should recur. Allowed: -1 (for last week of the month) ┃1┃2┃3┃4.
+ State the week of the month when the meeting should recur. If you use this field, you must also use the monthly_week_day field to state the day of the week when the meeting should recur.
+ Allowed: -1 (for last week of the month) ┃1┃2┃3┃4.
name: monthly_week
- description: >
For recurring meetings with Monthly recurrence_type only.
- State a specific day in a week when the monthly meeting should recur. Allowed: 1┃2┃3┃4┃5┃6┃7
+ State a specific day in a week when the monthly meeting should recur. Allowed: 1┃2┃3┃4┃5┃6┃7.
To use this field, you must also use the monthly_week field.
name: monthly_week_day
@@ -207,7 +234,7 @@ script:
description: If true, only authenticated users can join the meeting.
- name: user
required: true
- description: email address or id of user for meeting.
+ description: Email address or ID of the user for the meeting.
- name: topic
required: true
description: The topic of the meeting.
@@ -221,14 +248,14 @@ script:
description: Meeting start time. When using a format like “yyyy-MM-ddTHH:mm:ssZ”, always use GMT time. When using a format like “yyyy-MM-ddTHH:mm:ss”, you should use local time and you will need to specify the time zone. Only used for scheduled meetings and recurring meetings with fixed time.
- name: timezone
description: "Timezone to format start_time. For example, “America/Los_Angeles”. For scheduled meetings only. "
- description: Create a new zoom meeting (scheduled ,instant, or recurring).
+ description: Create a new Zoom meeting (scheduled, instant, or recurring).
name: zoom-create-meeting
outputs:
- contextPath: Zoom.Meeting.join_url
- description: Join url for the meeting.
+ description: Join the URL for the meeting.
type: string
- contextPath: Zoom.Meeting.id
- description: Meeting id of the new meeting that is created.
+ description: Meeting ID of the new meeting that is created.
type: string
- contextPath: Zoom.Meeting.start_url
description: The URL to start the meeting.
@@ -268,7 +295,7 @@ script:
predefined:
- "false"
- "true"
- description: Get meeting record and save as file in the warroom.
+ description: Get the meeting record and save it as a file in the War Room.
name: zoom-fetch-recording
outputs:
- contextPath: File.SHA256
@@ -281,22 +308,22 @@ script:
description: Attachment's MD5.
type: Unknown
- contextPath: File.Name
- description: Attachment's Name.
+ description: Attachment's name.
type: Unknown
- contextPath: File.Info
- description: Attachment's Info.
+ description: Attachment's info.
type: Unknown
- contextPath: File.Size
- description: Attachment's Size (In Bytes).
+ description: Attachment's size (in bytes).
type: Unknown
- contextPath: File.Extension
- description: Attachment's Extension.
+ description: Attachment's extension.
type: Unknown
- contextPath: File.Type
- description: Attachment's Type.
+ description: Attachment's type.
type: Unknown
- contextPath: File.EntryID
- description: Attachment's EntryID.
+ description: Attachment's entryID.
type: Unknown
- contextPath: File.SSDeep
description: Attachment's SSDeep hash.
@@ -325,14 +352,13 @@ script:
- name: limit
description: The total amunt of results to show.
- name: user_id
- description: A user ID. this is for a singel user.
+ description: A user ID. This is for a single user.
type: string
- name: role_id
description: >-
Filter the response by a specific role.
-
For example: role_id=0 (Owner), role_id=2 (Member).
- description: List the existing users.
+ description: List the existing users
name: zoom-list-users
outputs:
- contextPath: Zoom.Metadata.Count
@@ -351,22 +377,22 @@ script:
description: ID of the user.
type: string
- contextPath: Zoom.User.first_name
- description: First name of user.
+ description: First name of the user.
type: string
- contextPath: Zoom.User.last_name
- description: Last name of user.
+ description: Last name of the user.
type: string
- contextPath: Zoom.User.email
- description: Email of user.
+ description: Email of the user.
type: string
- contextPath: Zoom.User.type
description: Type of user.
type: number
- contextPath: Zoom.User.created_at
- description: Date when user was created.
+ description: Date when the user was created.
type: date
- contextPath: Zoom.User.dept
- description: Department for user.
+ description: Department for the user.
type: string
- contextPath: Zoom.User.verified
description: Is the user verified.
@@ -378,10 +404,10 @@ script:
description: Default timezone for the user.
type: string
- contextPath: Zoom.User.pmi
- description: PMI of user.
+ description: Personal meeting ID of the user.
type: string
- contextPath: Zoom.User.group_ids
- description: Groups user belongs to.
+ description: Groups the user belongs to.
type: string
- arguments:
- default: true
@@ -402,7 +428,7 @@ script:
arguments:
- name: meeting_id
required: true
- description: The id of the existing meeting.
+ description: The ID of the existing meeting.
type: string
- name: occurrence_id
description: Provide this field to view meeting details of a particular occurrence of the recurring meeting.
@@ -416,10 +442,10 @@ script:
defaultValue: true
outputs:
- contextPath: Zoom.Meeting.join_url
- description: Join url for the meeting.
+ description: Join URL for the meeting.
type: string
- contextPath: Zoom.Meeting.id
- description: Meeting id of the new meeting that is created.
+ description: Meeting ID of the new meeting that is created.
type: string
- contextPath: Zoom.Meeting.start_url
description: The URL to start the meeting.
@@ -431,7 +457,7 @@ script:
description: The status of the meeting.
type: string
- contextPath: Zoom.Meeting.start_time
- description: The time that the meeting will start at.
+ description: The time that the meeting will start.
type: Date
- contextPath: Zoom.Meeting.host email
description: The email of the host of this meeting.
@@ -448,7 +474,7 @@ script:
- contextPath: Zoom.Meeting.type
description: The type of the new meeting, Instant = 1, Scheduled =2,Recurring with fixed time = 8.
type: number
- description: Get the information of an existing zoom meeting.
+ description: Get the information of an existing Zoom meeting.
- name: zoom-meeting-list
arguments:
- name: user_id
@@ -491,10 +517,10 @@ script:
description: The total records in the API for this request.
type: number
- contextPath: Zoom.Meeting.join_url
- description: Join url for the meeting.
+ description: Join URL for the meeting.
type: string
- contextPath: Zoom.Meeting.id
- description: Meeting id of the new meeting that is created.
+ description: Meeting ID of the new meeting that is created.
type: string
- contextPath: Zoom.Meeting.start_url
description: The URL to start the meeting.
@@ -506,7 +532,7 @@ script:
description: The status of the meeting.
type: string
- contextPath: Zoom.Meeting.start_time
- description: The time that the meeting will start at.
+ description: The time that the meeting will start.
type: Date
- contextPath: Zoom.Meeting.host email
description: The email of the host of this meeting.
@@ -521,7 +547,7 @@ script:
description: The time that this meeting was created.
type: Date
- contextPath: Zoom.Meeting.type
- description: The ty pe of this meeting.
+ description: The type of this meeting.
description: >
Show all the meetings of a given user.
Note: only scheduled and unexpired meetings will appear.
@@ -657,15 +683,21 @@ script:
name: member_emails
required: true
isArray: true
- - description: >-
- Who can add new channel members: * 1 - All channel members can add new members. * 2 - Only channel owner and administrators can add new members. Note: This setting can only be modified by the channel owner. Default: 1.
+ - description: >
+ Who can add new channel members:
+ * 1 - All channel members can add new members.
+ * 2 - Only channel owner and administrators can add new members.
+ Note: This setting can only be modified by the channel owner. Default: 1 .
name: add_member_permissions
auto: PREDEFINED
predefined:
- All channel members can add
- Only channel owner and admins can add
- - description: >-
- The channel members' posting permissions: * 1 — All chat channel members can post to the channel. * 2 — Only the channel owner and administrators can post to the channel. * 3 — Only the channel owner, administrators and certain members can post to the channel. Default: 1.
+ - description: >
+ The channel members' posting permissions:
+ * 1 — All chat channel members can post to the channel.
+ * 2 — Only the channel owner and administrators can post to the channel.
+ * 3 — Only the channel owner, administrators and certain members can post to the channel. Default: 1 .
name: posting_permissions
auto: PREDEFINED
predefined:
@@ -845,7 +877,7 @@ script:
Sends chat messages on Zoom to either an individual user who is in your contact list or to a channel of which you are a member. Use the command zoom-list-users to get the user id by the user email (for to_contact).
Use the command zoom-list-user-channels in order to fetch the channel ID by the channel name (for to_channel).
In order to send a message, you need to provide at least a message (message text) and to_contact (contact to send) or to_channel (channel to send to), but not both of them.
- In order to send a style message you need to provide is-markdown=true and the message can contain a markdown format
+ In order to send a style message you need to provide is-markdown=true and the message can contain a markdown format.
If you are using mention markdown in a message, provide the email address in the at_contact argument.
arguments:
- name: user_id
@@ -1090,7 +1122,7 @@ script:
description: The queried date value.
- contextPath: Zoom.ChatMessage.from
type: date-time
- description: The queried from value. (Returned only if the from query parameter is used.).
+ description: The queried from value. (Returned only if the from query parameter is used.)
- contextPath: Zoom.ChatMessage.messages
type: array of object
description: Information about received messages and files.
@@ -1150,10 +1182,10 @@ script:
description: The message content.
- contextPath: Zoom.ChatMessage.messages.reply_main_message_id
type: string
- description: The unique identifier of a reply message. (Returned only for reply messages).
+ description: The unique identifier of a reply message. (Returned only for reply messages.)
- contextPath: Zoom.ChatMessage.messages.reply_main_message_timestamp
type: integer
- description: The timestamp of when the reply message was sent. (Returned only for reply messages).
+ description: The timestamp of when the reply message was sent. (Returned only for reply messages.)
- contextPath: Zoom.ChatMessage.messages.sender
type: string
description: The email address of the message sender. Empty if the sender does not belong to the same account as the current user or is not a contact.
@@ -1165,7 +1197,7 @@ script:
description: The display name of the message sender.
- contextPath: Zoom.ChatMessage.messages.status
type: enum
- description: Indicates the status of the message. Allowed values Deleted, Edited, Normal. (Returned only when include_deleted_and_edited_message query parameter is set to true).
+ description: Indicates the status of the message. Allowed values Deleted, Edited, Normal. (Returned only when include_deleted_and_edited_message query parameter is set to true.)
- contextPath: Zoom.ChatMessage.messages.timestamp
type: integer
description: The timestamp of the message in microseconds.
@@ -1187,8 +1219,69 @@ script:
- contextPath: Zoom.ChatMessage.at_items.start_position
type: integer
description: The start position of the mention.
-
- dockerimage: demisto/auth-utils:1.0.0.77807
+ - arguments:
+ - description: The name of the channel to archive. If not provided, the mirrored investigation channel is archived (if the channel exists).
+ name: channel
+ - description: The ID of the channel to archive. If not provided, the mirrored investigation channel is archived (if the channel exists).
+ name: channel_id
+ description: Delete a mirrored Zoom channel.
+ name: close-channel
+ - name: send-notification
+ description: Send a message using a chatbot app.
+ arguments:
+ - name: message
+ description: the message.
+ required: true
+ type: string
+ - name: to
+ description: The user ID, JID or email to whom to send the message. You must provide this parameter or the channel_id parameter.
+ type: string
+ - name: channel_id
+ description: The ID or the JID of the channel to which to send the message. You must provide this parameter or the to parameter.
+ type: string
+ - name: channel
+ type: string
+ description: The channel name can be provided only if the channel is part of a mirroring incident, instead of the channel JID .
+ - name: visible_to_user
+ description: The UserID that allows a chatbot to send a message to a group channel when it has only one designated person in that group channel to see the message.
+ type: string
+ - name: zoom_ask
+ description: The message as a JSON for asking questions to the user.
+ type: boolean
+ outputs: []
+ - arguments:
+ - auto: PREDEFINED
+ default: true
+ defaultValue: all
+ description: The mirroring type. Can be "all", which mirrors everything, "chat", which mirrors only chats (not commands), or "none", which stops all mirroring.
+ name: type
+ predefined:
+ - all
+ - chat
+ - none
+ - auto: PREDEFINED
+ defaultValue: 'true'
+ description: Whether the channel is auto-closed when an investigation is closed.
+ name: autoclose
+ predefined:
+ - 'true'
+ - 'false'
+ - auto: PREDEFINED
+ defaultValue: Both
+ description: The mirroring direction.
+ name: direction
+ predefined:
+ - Both
+ - FromDemisto
+ - ToDemisto
+ - description: The name of the channel. The default is "incident-".
+ name: channelName
+ description: Mirrors the investigation between Zoom and the Cortex XSOAR War Room.
+ name: mirror-investigation
+ runonce: false
+ longRunning: true
+ longRunningPort: true
+ dockerimage: demisto/fastapi:1.0.0.79757
script: "-"
subtype: python3
type: python
diff --git a/Packs/Zoom/Integrations/Zoom/Zoom_description.md b/Packs/Zoom/Integrations/Zoom/Zoom_description.md
index fa3cbae6ea66..3e9eb870bb18 100644
--- a/Packs/Zoom/Integrations/Zoom/Zoom_description.md
+++ b/Packs/Zoom/Integrations/Zoom/Zoom_description.md
@@ -1,6 +1,8 @@
## Zoom Integration
In order to use this integration, you need to enter your Zoom credentials in the relevant integration instance parameters.
-There are two authentication methods available: **OAuth** and **JWT**(deprecated).
+Authentication method available: **OAuth**
+
+**Note: JWT authentication method was deprecated by Zoom from June 2023 and not available anymore.**
Log in to your Zoom admin user account, and follow these steps:
Click [here](https://marketplace.zoom.us/develop/create) to create an app.
@@ -15,11 +17,16 @@ Click [here](https://marketplace.zoom.us/develop/create) to create an app.
For more information about creating an OAuth app click [here](https://marketplace.zoom.us/docs/guides/build/server-to-server-oauth-app/).
-### For the JWT method (deprecated)
-- Create an JWT app.
-- Use the following account credentials to get an access token:
- API Key
- API Secret
+### For sending messages using the ChatBot app and mirroring
-Note: This authentication method will be deprecated by Zoom in June 2023.
-For more information about creating an JWT app click [here](https://marketplace.zoom.us/docs/guides/build/jwt-app/).
+To enable the integration to communicate directly with Zoom for mirroring or to send messages by Zoom chatbot,
+you must create a dedicated Zoom Team Chat app for the Cortex XSOAR integration.
+Make sure you have the following parameters:
+- Long running instance enabled
+- Listen Port
+- Bot JID
+- Bot Client ID
+- Bot Secret ID
+- Secret Token
+- Enable Incident Mirroring
+ For instructions on how to create and configure your custom Zoom app, review the documentation found [here](https://xsoar.pan.dev/docs/reference/integrations/zoom).
\ No newline at end of file
diff --git a/Packs/Zoom/Integrations/Zoom/Zoom_test.py b/Packs/Zoom/Integrations/Zoom/Zoom_test.py
index 847009df8f8d..5aef2afa4549 100644
--- a/Packs/Zoom/Integrations/Zoom/Zoom_test.py
+++ b/Packs/Zoom/Integrations/Zoom/Zoom_test.py
@@ -1,8 +1,13 @@
+from fastapi import Request, status
+import json
+from unittest.mock import patch
+from fastapi.security import HTTPBasicCredentials
from Zoom import Client
import Zoom
import pytest
-from CommonServerPython import DemistoException
+from CommonServerPython import DemistoException, CommandResults
import demistomock as demisto
+from freezegun import freeze_time
def test_zoom_list_users_command__limit(mocker):
@@ -717,7 +722,7 @@ def test_check_start_time_format__wrong_format():
def test_test_moudle__reciving_errors(mocker):
- mocker.patch.object(Client, "generate_oauth_token")
+ mocker.patch.object(Client, "get_oauth_token", return_value=("token", None))
client = Client(base_url='https://test.com', account_id="mockaccount",
client_id="mockclient", client_secret="mocksecret")
mocker.patch.object(Client, "zoom_list_users", side_effect=DemistoException('Invalid access token'))
@@ -727,7 +732,7 @@ def test_test_moudle__reciving_errors(mocker):
def test_test_moudle__reciving_errors1(mocker):
- mocker.patch.object(Client, "generate_oauth_token")
+ mocker.patch.object(Client, "get_oauth_token", return_value=("token", None))
client = Client(base_url='https://test.com', account_id="mockaccount",
client_id="mockclient", client_secret="mocksecret")
mocker.patch.object(Client, "zoom_list_users", side_effect=DemistoException("The Token's Signature resulted invalid"))
@@ -737,7 +742,7 @@ def test_test_moudle__reciving_errors1(mocker):
def test_test_moudle__reciving_errors2(mocker):
- mocker.patch.object(Client, "generate_oauth_token")
+ mocker.patch.object(Client, "get_oauth_token", return_value=("token", None))
client = Client(base_url='https://test.com', account_id="mockaccount",
client_id="mockclient", client_secret="mocksecret")
mocker.patch.object(Client, "zoom_list_users", side_effect=DemistoException("Invalid client_id or client_secret"))
@@ -747,7 +752,7 @@ def test_test_moudle__reciving_errors2(mocker):
def test_test_moudle__reciving_errors3(mocker):
- mocker.patch.object(Client, "generate_oauth_token")
+ mocker.patch.object(Client, "get_oauth_token", return_value=("token", None))
client = Client(base_url='https://test.com', account_id="mockaccount",
client_id="mockclient", client_secret="mocksecret")
mocker.patch.object(Client, "zoom_list_users", side_effect=DemistoException("mockerror"))
@@ -1766,3 +1771,488 @@ def test_zoom_get_user_id_by_email(mocker):
result = zoom_get_user_id_by_email(client, email)
mock_zoom_list_users.assert_called_with(page_size=50, url_suffix=f'users/{email}')
assert result == expected_user_id
+
+
+def test_zoom_send_notification_command(mocker):
+ """
+ Given -
+ client
+ When -
+ send message to channel
+ Then -
+ Validate that the zoom_send_message function is called with the correct arguments
+ Validate the command results including outputs and readable output
+ """
+ client = Client(base_url='https://test.com', account_id="mockaccount",
+ client_id="mockclient", client_secret="mocksecret", bot_client_id="mockclient",
+ bot_client_secret="mocksecret")
+ client.bot_jid = 'mock_bot'
+
+ expected_request_payload = {
+ 'robot_jid': 'mock_bot',
+ 'to_jid': 'channel1@xmpp.zoom.us',
+ 'account_id': 'mockaccount',
+ 'content': {'head': {'type': 'message', 'text': 'Hello'}}
+ }
+
+ expected_response = {
+ 'message_id': 'message_id',
+ }
+
+ mock_send_chat_message = mocker.patch.object(client, 'zoom_send_notification')
+ mock_send_chat_message.return_value = expected_response
+ from Zoom import send_notification
+
+ result = send_notification(client,
+ user_id='user1',
+ message='Hello',
+ to='channel1',
+ )
+
+ assert result.readable_output == 'Message sent to Zoom successfully. Message ID is: message_id'
+ assert mock_send_chat_message.call_args[0][1] == expected_request_payload
+
+
+@pytest.mark.parametrize("channel_name, investigation_id, expected_result", [
+ ('Channel1', None, 'JID1'), # Scenario 1: Find by channel_name
+ (None, 'Incident123', 'JID1'), # Scenario 2: Find by investigation_id
+ ('NonExistentChannel', None, None), # Scenario 3: Channel not found
+])
+def test_get_channel_jid_by_channel_name(channel_name, investigation_id, expected_result, mocker):
+ """
+ Given different scenarios with channel_name and investigation_id parameters,
+ When calling the get_channel_jid_from_context function,
+ Then validate that the function returns the expected result.
+ """
+ # Mock integration context
+ Zoom.CACHE_EXPIRY = False
+ mock_integration_context = {
+ 'mirrors': json.dumps([
+ {'channel_name': 'Channel1', 'channel_jid': 'JID1', 'investigation_id': 'Incident123'},
+ {'channel_name': 'Channel2', 'channel_jid': 'JID2', 'investigation_id': 'Incident123'},
+ ])
+ }
+ mocker.patch.object(demisto, 'getIntegrationContext', return_value=mock_integration_context)
+
+ # Call the function
+ from Zoom import get_channel_jid_from_context
+ result = get_channel_jid_from_context(channel_name, investigation_id)
+
+ # Assert the result
+ assert result == expected_result
+
+
+# Test cases for check_authentication_bot_parameters
+@pytest.mark.parametrize("bot_Jid, client_id, client_secret, expected_exception", [
+ ('bot_Jid', 'client_id', 'client_secret', None), # Scenario 1: All parameters provided
+ (None, None, None, None), # Scenario 2: All parameters None
+ ('bot_Jid', None, None, DemistoException), # Scenario 3: bot_Jid provided, others None
+ (None, 'client_id', None, DemistoException), # Scenario 4: client_id provided, others None
+ (None, None, 'client_secret', DemistoException), # Scenario 5: client_secret provided, others None
+])
+def test_check_authentication_bot_parameters(bot_Jid, client_id, client_secret, expected_exception):
+ """
+ Given different scenarios with bot_Jid, client_id, and client_secret parameters,
+ When calling the check_authentication_bot_parameters function,
+ Then validate that the function raises the expected exception or returns without raising an exception.
+ """
+ from Zoom import check_authentication_bot_parameters
+ if expected_exception:
+ with pytest.raises(expected_exception):
+ check_authentication_bot_parameters(bot_Jid, client_id, client_secret)
+ else:
+ check_authentication_bot_parameters(bot_Jid, client_id, client_secret)
+
+
+def test_get_admin_user_id_from_token(mocker):
+ """
+ Given a mock client with a zoom_get_admin_user_id_from_token method,
+ When calling the get_admin_user_id_from_token function,
+ Then validate that the function returns the expected user ID.
+ """
+ # Create a mock client
+ client = Client(base_url='https://test.com', account_id="mockaccount",
+ client_id="mockclient", client_secret="mocksecret")
+ mocker.patch.object(client, 'zoom_get_admin_user_id_from_token', return_value={'id': 'mock_user_id'})
+ # Call the function
+ from Zoom import get_admin_user_id_from_token
+ result = get_admin_user_id_from_token(client)
+
+ # Assert the result
+ assert result == 'mock_user_id'
+
+
+def test_mirror_investigation_create_new_channel(mocker):
+ """
+ Given a mock client and relevant arguments,
+ When calling the mirror_investigation function to create a new channel,
+ Then validate that the function returns the expected CommandResults.
+ """
+ Zoom.MIRRORING_ENABLED = True
+ Zoom.LONG_RUNNING = True
+ client = Client(base_url='https://test.com', account_id="mockaccount",
+ client_id="mockclient", client_secret="mocksecret")
+ client.bot_jid = 'mock_jid'
+ mocker.patch.object(client, 'zoom_send_notification')
+ mocker.patch.object(Zoom, 'get_admin_user_id_from_token', return_value='mock_user_id')
+ mocker.patch.object(Zoom, 'zoom_create_channel_command',
+ return_value=CommandResults(outputs={"jid": "mock_jid", "id": "mock_id"}))
+ mocker.patch.object(demisto, 'demistoUrls', return_value={'server': 'mock_server_url'})
+ # mocker.patch.object(client, 'botJid', return_value='bot_jid_mock')
+
+ # Test data
+ args = {
+ 'type': 'all',
+ 'direction': 'Both',
+ 'channelName': 'mirror-channel',
+ 'autoclose': True,
+ }
+
+ # Call the function
+ from Zoom import mirror_investigation
+ result = mirror_investigation(client, **args)
+
+ # Assert the result
+ assert 'Investigation mirrored successfully' in result.readable_output
+
+
+@pytest.mark.asyncio
+async def test_check_and_handle_entitlement(mocker):
+ """
+ Test the asynchronous function check_and_handle_entitlement.
+ Given:
+ - Input parameters for the function: text, message_id, user_name.
+ When:
+ - Calling the asynchronous function check_and_handle_entitlement with the given input parameters.
+ Then:
+ - Validate that the function behaves as expected and returns the expected result.
+ """
+ # Mock integration context
+ mock_integration_context = {
+ 'messages': json.dumps([
+ {'message_id': 'MessageID123',
+ 'entitlement': '3dcaae6d-d4d2-45b3-81a7-834bce779009@2b03d219-bbac-4333-84a2-d329d7296baa',
+ 'reply': 'thanks', 'expiry': '2023-08-29 12:32:40', 'sent': '2023-08-29 12:27:42', 'default_response': 'NoResponse'}
+ ])
+ }
+ mocker.patch.object(demisto, 'getIntegrationContext', return_value=mock_integration_context)
+ Zoom.CACHE_EXPIRY = False
+ # Define the input parameters for the function
+ text = "Entitlement Text"
+ message_id = "MessageID123"
+ user_name = "User123"
+
+ # Call the async function and await its result
+ from Zoom import check_and_handle_entitlement
+ result = await check_and_handle_entitlement(text, message_id, user_name)
+
+ assert result == 'thanks' # Adjust the expected reply as needed
+
+
+@pytest.mark.parametrize("entitlement, expected_result", [
+ ("guid123@incident456|task789", ("guid123", "incident456", "task789")), # Scenario 1: Full entitlement
+ ("guid123@incident456", ("guid123", "incident456", "")), # Scenario 2: No task ID
+ ("guid123@", ("guid123", "", "")), # Scenario 3: No incident ID or task ID
+])
+def test_extract_entitlement(entitlement, expected_result):
+ """
+ Test the extract_entitlement function.
+ Given:
+ - Input entitlement string.
+ When:
+ - Calling the extract_entitlement function with the given input entitlement.
+ Then:
+ - Validate that the function correctly extracts the entitlement components: guid, incident_id, and task_id.
+ """
+ from Zoom import extract_entitlement
+ result = extract_entitlement(entitlement)
+
+ # Assert the result against the expected outcome
+ assert result == expected_result
+
+
+# @pytest.mark.asyncio
+# async def test_check_for_unanswered_questions(mocker):
+# mock_integration_context = {
+# 'messages': json.dumps([
+# {
+# 'message_id': 'MessageID1',
+# 'expiry': '2023-08-29 12:00:00',
+# 'default_response': 'DefaultResponse1'
+# },
+# {
+# 'message_id': 'MessageID2',
+# 'expiry': '2023-08-29 14:00:00',
+# 'default_response': 'DefaultResponse2'
+# },
+# ])
+# }
+# mocker.patch.object(demisto, 'getIntegrationContext', return_value=mock_integration_context)
+
+
+@pytest.mark.asyncio
+async def test_answer_question(mocker):
+ """
+ Test the answer_question function.
+ Given:
+ - A mocked question dictionary.
+ When:
+ - Calling the answer_question function with the mocked question.
+ Then:
+ - Validate that the function correctly handles the entitlement and returns the incident_id.
+ """
+
+ mock_question = {
+ 'entitlement': 'guid123@incident456|task789',
+ 'to_jid': 'ToJID123'
+ }
+ Zoom.CLIENT = Client(base_url='https://test.com', account_id="mockaccount",
+ client_id="mockclient", client_secret="mocksecret")
+ Zoom.CLIENT.bot_jid = 'mock_bot_id'
+ mocker.patch.object(Zoom, 'process_entitlement_reply')
+
+ from Zoom import answer_question
+ result = await answer_question("Answer123", mock_question, "user@example.com")
+ assert result == 'incident456'
+
+
+@pytest.mark.asyncio
+async def test_process_entitlement_reply(mocker):
+ """
+ Test the process_entitlement_reply function.
+
+ Given:
+ - Mocked input parameters.
+
+ When:
+ - Calling the process_entitlement_reply function with the mocked parameters.
+
+ Then:
+ - Validate that the function correctly prepares and sends a Zoom notification.
+ """
+ # Mocked input parameters
+ mock_entitlement_reply = "Entitlement Reply"
+ mock_account_id = "mock_account_id"
+ mock_robot_jid = "mock_robot_jid"
+ mock_to_jid = "mock_to_jid"
+ client = Client(base_url='https://test.com', account_id="mockaccount",
+ client_id="mockclient", client_secret="mocksecret")
+ # Mock the CLIENT.zoom_send_notification function
+ Zoom.CLIENT = client
+ mock_zoom_send_notification = mocker.AsyncMock()
+ mock_zoom_send_notification = mocker.patch.object(Zoom, 'zoom_send_notification_async')
+
+ # Call the function with the mocked parameters
+ from Zoom import process_entitlement_reply
+ await process_entitlement_reply(mock_entitlement_reply, mock_account_id, mock_robot_jid, mock_to_jid)
+
+ # Assert that the CLIENT.zoom_send_notification function was called with the correct arguments
+ mock_zoom_send_notification.assert_called_with(client,
+ '/im/chat/messages',
+ {
+ "content": {
+ "body": [
+ {
+ "type": "message",
+ "text": mock_entitlement_reply
+ }
+ ]
+ },
+ "to_jid": mock_to_jid,
+ "robot_jid": mock_robot_jid,
+ "account_id": mock_account_id
+ }
+ )
+
+
+# Test cases
+@pytest.mark.asyncio
+async def test_close_channel(mocker):
+ """
+ Test the close_channel function
+ Given:
+ - Mocked input parameters.
+ When:
+ - Calling the close_channel function.
+ Then:
+ - Ensure that the function successfully closes the channel.
+ """
+ mock_integration_context = {
+ 'mirrors': json.dumps([
+ {'channel_name': 'Channel1', 'channel_jid': 'JID1', 'channel_id': 'ID1',
+ 'investigation_id': 'Incident123', 'mirror_direction': 'both', 'auto_close': True},
+ {'channel_name': 'Channel2', 'channel_jid': 'JID2', 'channel_id': 'ID2',
+ 'investigation_id': 'Incident123', 'mirror_direction': 'both', 'auto_close': True},
+ ])
+ }
+ client = Client(base_url='https://test.com', account_id="mockaccount",
+ client_id="mockclient", client_secret="mocksecret")
+
+ mocker.patch.object(Zoom, 'zoom_delete_channel_command')
+ mocker.patch.object(demisto, 'mirrorInvestigation')
+ mocker.patch.object(Zoom, 'get_integration_context', return_value=mock_integration_context)
+ mocker.patch.object(Zoom, 'set_to_integration_context_with_retries')
+ mocker.patch.object(Zoom, 'get_admin_user_id_from_token', return_value='mock_user_id')
+ mocker.patch.object(Zoom, 'find_mirror_by_investigation', return_value={'channel_id': 'ID1'})
+
+ from Zoom import close_channel
+ result = close_channel(client)
+
+ assert result == 'Channel successfully deleted.'
+
+
+@pytest.mark.parametrize("event_type, expected_status", [
+ ('endpoint.url_validation', status.HTTP_200_OK),
+ ('interactive_message_actions', status.HTTP_200_OK),
+ ('invalid_event_type', status.HTTP_400_BAD_REQUEST)
+])
+@pytest.mark.asyncio
+async def test_handle_zoom_response(event_type, expected_status,
+ mocker):
+ """
+ Test the handle_zoom_response function with different event types and payload conditions.
+
+ Given:
+ - Mocked request object.
+ - Mocked event_url_validation, check_and_handle_entitlement, process_entitlement_reply, and handle_mirroring functions.
+ - Parameters for event types and payload conditions.
+
+ When:
+ - Calling the handle_zoom_response function with different event types and payload conditions.
+
+ Then:
+ - Ensure that the function returns the expected response status code.
+ """
+ mock_request = mocker.Mock(spec=Request)
+ json_res = {
+ "plainToken": 123,
+ "encryptedToken": 123
+ }
+ mocker.patch('Zoom.event_url_validation', return_value=json_res)
+ mocker.patch('Zoom.check_and_handle_entitlement')
+ mocker.patch('Zoom.process_entitlement_reply')
+ mocker.patch('Zoom.handle_mirroring')
+ mocker.patch.object(demisto, 'params', return_value={'credentials': {'identifier': 'test', 'password': 'testpass'}})
+
+ # Create a mock HTTPBasicCredentials object
+ mock_credentials = HTTPBasicCredentials(
+ username="test",
+ password="testpass"
+ )
+
+ Zoom.SECRET_TOKEN = 'token'
+
+ from Zoom import handle_zoom_response
+
+ mock_request.json.return_value = {
+ "event": event_type,
+ "payload": {
+ "accountId": "mock_accountid",
+ "actionItem": {
+ "text": "no",
+ "value": "no",
+ "action": "command"
+ },
+ "messageId": "message_id",
+ "robotJid": "robot_jid",
+ "toJid": "mock_jid",
+ "userName": "admin zoom"
+ }
+ }
+
+ response = await handle_zoom_response(mock_request, mock_credentials)
+
+ assert response.status_code == expected_status
+
+
+@pytest.mark.asyncio
+async def test_event_url_validation():
+ Zoom.SECRET_TOKEN = "secret_token"
+
+ # Define the payload for testing
+ payload = {
+ "plainToken": "plain_token"
+ }
+
+ # Calculate the expected signature
+ import hashlib
+ import hmac
+ hash_object = hmac.new(Zoom.SECRET_TOKEN.encode('utf-8'), msg=payload['plainToken'].encode('utf-8'), digestmod=hashlib.sha256)
+ expected_signature = hash_object.hexdigest()
+
+ from Zoom import event_url_validation
+ response = await event_url_validation(payload)
+
+ # Verify that the response matches the expected signature
+ assert response == {
+ "plainToken": payload["plainToken"],
+ "encryptedToken": expected_signature
+ }
+
+
+@pytest.mark.asyncio
+async def test_handle_text(mocker):
+ # Create mock arguments
+ investigation_id = "123"
+ text = "Hello, this is a test message"
+ operator_email = "test@example.com"
+ operator_name = "Test User"
+ MESSAGE_FOOTER = '\n**From Zoom**'
+ from Zoom import handle_text_received_from_zoom
+
+ with patch('Zoom.demisto') as mock_demisto:
+ # Call the function
+ await handle_text_received_from_zoom(investigation_id, text, operator_email, operator_name)
+ # Assert that the `demisto.addEntry` method was called with the expected arguments
+ mock_demisto.addEntry.assert_called_once_with(
+ id=investigation_id,
+ entry=text,
+ username=operator_name,
+ email=operator_email,
+ footer=MESSAGE_FOOTER # Assuming MESSAGE_FOOTER is defined in your module
+ )
+
+
+def test_save_entitlement():
+ # Define test inputs
+ entitlement = "Test Entitlement"
+ message_id = "123"
+ reply = "Test Reply"
+ expiry = "2023-09-09"
+ default_response = "Default Response"
+ to_jid = "user@example.com"
+ SYNC_CONTEXT = True
+ OBJECTS_TO_KEYS = {
+ 'messages': 'entitlement',
+ }
+ # Mock the required functions (get_integration_context, set_to_integration_context_with_retries) and any other dependencies
+ with patch('Zoom.get_integration_context') as mock_get_integration_context, \
+ patch('Zoom.set_to_integration_context_with_retries') as mock_set_integration_context:
+
+ # Mock the return values of the mocked functions
+ mock_get_integration_context.return_value = {'messages': []}
+ fixed_timestamp = '2023-09-09 20:08:50'
+
+ with freeze_time(fixed_timestamp):
+ from Zoom import save_entitlement
+ # Call the function to be tested
+ save_entitlement(entitlement, message_id, reply, expiry, default_response, to_jid)
+
+ # Define the expected data to be added to integration context
+ expected_data = {
+ 'messages': [
+ {
+ 'message_id': message_id,
+ 'entitlement': entitlement,
+ 'reply': reply,
+ 'expiry': expiry,
+ 'sent': fixed_timestamp,
+ 'default_response': default_response,
+ 'to_jid': to_jid
+ }
+ ]
+ }
+
+ # Assert that the mocked functions were called with the expected arguments
+ mock_get_integration_context.assert_called_once_with(SYNC_CONTEXT)
+ mock_set_integration_context.assert_called_once_with(expected_data, OBJECTS_TO_KEYS, SYNC_CONTEXT)
diff --git a/Packs/Zoom/Integrations/Zoom/doc_files/add-event.gif b/Packs/Zoom/Integrations/Zoom/doc_files/add-event.gif
new file mode 100644
index 000000000000..02dbeaa8a9f5
Binary files /dev/null and b/Packs/Zoom/Integrations/Zoom/doc_files/add-event.gif differ
diff --git a/Packs/Zoom/Integrations/Zoom/doc_files/authentication_header.png b/Packs/Zoom/Integrations/Zoom/doc_files/authentication_header.png
new file mode 100644
index 000000000000..83c51c804c63
Binary files /dev/null and b/Packs/Zoom/Integrations/Zoom/doc_files/authentication_header.png differ
diff --git a/Packs/Zoom/Integrations/Zoom/doc_files/bot_endpoint_url.gif b/Packs/Zoom/Integrations/Zoom/doc_files/bot_endpoint_url.gif
new file mode 100644
index 000000000000..670579f371d2
Binary files /dev/null and b/Packs/Zoom/Integrations/Zoom/doc_files/bot_endpoint_url.gif differ
diff --git a/Packs/Zoom/Integrations/Zoom/doc_files/create-team-chat-app.gif b/Packs/Zoom/Integrations/Zoom/doc_files/create-team-chat-app.gif
new file mode 100644
index 000000000000..32a2210352bf
Binary files /dev/null and b/Packs/Zoom/Integrations/Zoom/doc_files/create-team-chat-app.gif differ
diff --git a/Packs/Zoom/Integrations/Zoom/doc_files/scope-premissions.png b/Packs/Zoom/Integrations/Zoom/doc_files/scope-premissions.png
new file mode 100644
index 000000000000..24b7a5f351d1
Binary files /dev/null and b/Packs/Zoom/Integrations/Zoom/doc_files/scope-premissions.png differ
diff --git a/Packs/Zoom/Integrations/Zoom/doc_files/test-zoom-app.gif b/Packs/Zoom/Integrations/Zoom/doc_files/test-zoom-app.gif
new file mode 100644
index 000000000000..9be89d269f16
Binary files /dev/null and b/Packs/Zoom/Integrations/Zoom/doc_files/test-zoom-app.gif differ
diff --git a/Packs/Zoom/Integrations/Zoom/doc_files/verification.png b/Packs/Zoom/Integrations/Zoom/doc_files/verification.png
new file mode 100644
index 000000000000..d2f625ec50c1
Binary files /dev/null and b/Packs/Zoom/Integrations/Zoom/doc_files/verification.png differ
diff --git a/Packs/Zoom/Integrations/Zoom/doc_files/zoom-token.png b/Packs/Zoom/Integrations/Zoom/doc_files/zoom-token.png
new file mode 100644
index 000000000000..48eccf354c56
Binary files /dev/null and b/Packs/Zoom/Integrations/Zoom/doc_files/zoom-token.png differ
diff --git a/Packs/Zoom/Integrations/ZoomEventCollector/ZoomEventCollector_test.py b/Packs/Zoom/Integrations/ZoomEventCollector/ZoomEventCollector_test.py
index 57b8eeda2584..3728637b4c3a 100644
--- a/Packs/Zoom/Integrations/ZoomEventCollector/ZoomEventCollector_test.py
+++ b/Packs/Zoom/Integrations/ZoomEventCollector/ZoomEventCollector_test.py
@@ -3,7 +3,6 @@
import demistomock as demisto # noqa: F401
from datetime import datetime, timezone
import json
-import io
from freezegun import freeze_time
BASE_URL = "https://api.zoom.us/v2/"
@@ -11,7 +10,7 @@
def util_load_json(path):
- with io.open(path, mode='r', encoding='utf-8') as f:
+ with open(path, encoding='utf-8') as f:
return json.loads(f.read())
@@ -53,7 +52,7 @@ def test_main(first_fetch_time, expected_result, expect_error, mocker):
mocker.patch.object(demisto, "command", return_value="test-module")
mocker.patch.object(demisto, 'results')
- mocker.patch('ZoomEventCollector.Client.get_oauth_token', return_value='token')
+ mocker.patch('ZoomEventCollector.Client.get_oauth_token', return_value=('token', None))
mocker.patch('ZoomEventCollector.Client.search_events', return_value={
"from": "2023-03-31",
"to": "2023-04-01",
@@ -103,7 +102,7 @@ def test_fetch_events(mocker):
util_load_json('test_data/fetch_events_activities.json').get('fetch_events'),
])
- mocker.patch('ZoomEventCollector.Client.get_oauth_token', return_value='token')
+ mocker.patch('ZoomEventCollector.Client.get_oauth_token', return_value=('token', None))
mocker.patch.object(Client, "generate_oauth_token")
client = Client(base_url=BASE_URL)
@@ -164,7 +163,7 @@ def test_fetch_events_with_last_run(mocker):
util_load_json('test_data/fetch_events_activities.json').get('fetch_events_with_token_next')
])
- mocker.patch('ZoomEventCollector.Client.get_oauth_token', return_value='token')
+ mocker.patch('ZoomEventCollector.Client.get_oauth_token', return_value=('token', None))
mocker.patch.object(Client, "generate_oauth_token")
client = Client(base_url=BASE_URL)
@@ -210,7 +209,7 @@ def test_get_events_command(mocker):
util_load_json('test_data/get_events_activities.json')
])
- mocker.patch('ZoomEventCollector.Client.get_oauth_token', return_value='token')
+ mocker.patch('ZoomEventCollector.Client.get_oauth_token', return_value=('token', None))
mocker.patch.object(Client, "generate_oauth_token")
client = Client(base_url=BASE_URL)
diff --git a/Packs/Zoom/ReleaseNotes/1_6_0.json b/Packs/Zoom/ReleaseNotes/1_6_0.json
new file mode 100644
index 000000000000..849cdd0b0bde
--- /dev/null
+++ b/Packs/Zoom/ReleaseNotes/1_6_0.json
@@ -0,0 +1,4 @@
+{
+ "breakingChanges": true,
+ "breakingChangesNotes": "Removed JWT authentication."
+}
\ No newline at end of file
diff --git a/Packs/Zoom/ReleaseNotes/1_6_0.md b/Packs/Zoom/ReleaseNotes/1_6_0.md
new file mode 100644
index 000000000000..40a590b8b31c
--- /dev/null
+++ b/Packs/Zoom/ReleaseNotes/1_6_0.md
@@ -0,0 +1,15 @@
+
+#### Integrations
+
+##### Zoom
+- Updated the Docker image to: *demisto/fastapi:1.0.0.79757*.
+- **Breaking changes**: Removed JWT authentication.
+- Added 2 commands:
+ - ***send-notification***
+ - ***mirror-investigation***
+
+#### Scripts
+
+##### New: ZoomAsk
+
+New: Sends a message (question) to either a user (in a direct message) or to a channel. The message includes predefined reply options. The response can also close a task (might be conditional) in a playbook.
\ No newline at end of file
diff --git a/Packs/Zoom/Scripts/ZoomAsk/README.md b/Packs/Zoom/Scripts/ZoomAsk/README.md
new file mode 100644
index 000000000000..94f2c1e47076
--- /dev/null
+++ b/Packs/Zoom/Scripts/ZoomAsk/README.md
@@ -0,0 +1,57 @@
+Sends a message (question) to either a user (in a direct message) or to a channel. The message includes predefined reply options. The response can also close a task (might be conditional) in a playbook.
+
+## Script Data
+---
+
+| **Name** | **Description** |
+| --- | --- |
+| Script Type | python3 |
+| Tags | Zoom |
+| Version | 5.5.0 |
+
+## Use Case
+---
+This automation allows you to ask users in Zoom (including users who are external to Cortex XSOAR) questions, have them respond and
+reflect the answer back to Cortex XSOAR.
+
+## Dependencies
+---
+Requires an instance of the Zoom integration with Long Running instance checked.
+
+This script uses the following commands and scripts.
+send-notification
+
+## Inputs
+---
+
+| **Argument Name** | **Description** |
+| --- | --- |
+| user | The Zoom user to whom to send the message. Can be either an email address or a Zoom user_id. |
+| channel_id | The Zoom channel_id to which to send the message. |
+| message | The message to send to the user or channel. |
+| option1 | The first reply option. The default is "Yes" with a blue button. To change the color of the button, add the pound sign \(\#\) followed by the name of the new color \(blue, red, or black\). The default color is "Blue". For example, "Yes\#blue". |
+| option2 | The second reply option. The default is "No" with a red button. To change the button color, add the pound sign \(\#\) followed by the name of the new color \(green, red, or black\). The default color is "red". For example, "No\#red". |
+| task | The task to close with the reply. If empty, then no playbook tasks will be closed. |
+| persistent | Indicates whether to use one-time entitlement or persistent entitlement. |
+| responseType | How the user should respond to the question. |
+| additionalOptions | A comma-separated list of additional options in the format of "option\#color", for example, "maybe\#red". The default color is "black". |
+| reply | The reply to send to the user. Use the templates \{user\} and \{response\} to incorporate these in the reply. \(i.e., "Thank you \{user\}. You have answered \{response\}."\) |
+| lifetime | Time until the question expires. For example - 1 day. When it expires, a default response is sent. |
+| defaultResponse | Default response in case the question expires. |
+
+## Outputs
+---
+There are no outputs for this script.
+
+## Guide
+---
+The automation is most useful in a playbook to determine the outcome of a conditional task - which will be one of the provided options.
+It uses a mechanism that allows external users to respond in Cortex XSOAR (per investigation) with entitlement strings embedded within the message contents.
+![SlackAsk](https://user-images.githubusercontent.com/35098543/66044107-7de39f00-e529-11e9-8099-049502b4d62f.png)
+
+The automation can utilize the interactive capabilities of Zoom to send a form with buttons.
+This requires the external endpoint for interactive responses to be available for connection. (See the Zoom integration documentation for more information).
+You can also utilize a dropdown list instead, by specifying the `responseType` argument.
+
+## Notes
+---
\ No newline at end of file
diff --git a/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk.py b/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk.py
new file mode 100644
index 000000000000..6ad799e0618e
--- /dev/null
+++ b/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk.py
@@ -0,0 +1,152 @@
+import demistomock as demisto
+from CommonServerPython import *
+from CommonServerUserPython import *
+import dateparser
+
+STYLES_DICT = {
+ 'white': 'Default',
+ 'blue': 'Primary',
+ 'red': 'Danger',
+ 'gray': 'Disabled',
+}
+
+DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
+
+
+def parse_option_text(option_text):
+ # Function to parse the option text with color information and extract the text and style
+ # Format of the option text: "#"
+ parts = option_text.split("#", 1)
+ if len(parts) == 2:
+ text, color = parts
+ style = STYLES_DICT.get(color.lower(), 'Default')
+ else:
+ text = option_text
+ style = 'Default'
+ return text, style
+
+
+def generate_json(text, options, response_type):
+ if response_type == "button":
+ # Generate JSON for button type response
+ button_items = []
+ for _i, option in enumerate(options):
+ option_text, option_style = parse_option_text(option)
+ button_item = {
+ "value": option_text,
+ "style": option_style,
+ "text": option_text
+ }
+ button_items.append(button_item)
+
+ json_data = {
+ "head": {
+ "type": "message",
+ "text": text
+ },
+ "body": [
+ {
+ "type": "actions",
+ "items": button_items
+ }
+ ]
+ }
+
+ elif response_type == "dropdown":
+ # Generate JSON for dropdown type response
+ select_items = []
+ for _i, option in enumerate(options):
+ option_text, _ = parse_option_text(option)
+ select_item = {
+ "value": option_text,
+ "text": option_text
+ }
+ select_items.append(select_item)
+
+ json_data = {
+ "body": [
+ {
+ "select_items": select_items,
+ "text": text,
+ "selected_item": {
+ "value": select_items[0].get('value')
+ },
+ "type": "select"
+ }
+ ]
+ }
+
+ else:
+ raise ValueError("Invalid responseType. should be 'button' or 'dropdown'.")
+ return json_data
+
+
+def main():
+ demisto_args = demisto.args()
+ res = demisto.executeCommand('addEntitlement', {'persistent': demisto_args.get('persistent')})
+ if isError(res[0]):
+ return_results(res)
+ entitlement = demisto.get(res[0], 'Contents')
+ option1 = demisto_args.get('option1')
+ option2 = demisto_args.get('option2')
+ extra_options = argToList(demisto_args.get('additionalOptions', ''))
+ reply = demisto_args.get('reply')
+ response_type = demisto_args.get('responseType', 'buttons')
+ lifetime = demisto_args.get('lifetime', '1 day')
+ try:
+ parsed_date = dateparser.parse('in ' + lifetime, settings={'TIMEZONE': 'UTC'})
+ assert parsed_date is not None, f'Could not parse in {lifetime}'
+ expiry = datetime.strftime(parsed_date,
+ DATE_FORMAT)
+ except Exception:
+ parsed_date = dateparser.parse('in 1 day', settings={'TIMEZONE': 'UTC'})
+ assert parsed_date is not None
+ expiry = datetime.strftime(parsed_date,
+ DATE_FORMAT)
+ default_response = demisto_args.get('defaultResponse')
+
+ entitlement_string = entitlement + '@' + demisto.investigation()['id']
+ if demisto_args.get('task'):
+ entitlement_string += '|' + demisto_args.get('task')
+
+ args = {
+ 'ignoreAddURL': 'true',
+ }
+
+ user_options = [option1, option2]
+ if extra_options:
+ user_options += extra_options
+ blocks = json.dumps(generate_json(demisto.args()['message'], user_options, response_type))
+ args['message'] = json.dumps({
+ 'blocks': blocks,
+ 'entitlement': entitlement_string,
+ 'reply': reply,
+ 'expiry': expiry,
+ 'default_response': default_response
+ })
+ to = demisto_args.get('user')
+ channel_name = demisto_args.get('channel_name')
+ channel = demisto_args.get('channel_id')
+
+ if to:
+ args['to'] = to
+ elif channel:
+ args['channel_id'] = channel
+ elif channel_name:
+ args['channel'] = channel_name
+ else:
+ return_error('Either a user or a channel_id or channel_name must be specified')
+
+ args['zoom_ask'] = 'true'
+ try:
+ return_results(demisto.executeCommand('send-notification', args))
+ except ValueError as e:
+ if 'Unsupported Command' in str(e):
+ return_error(f'The command is unsupported by this script. {e}')
+ else:
+ return_error('An error has occurred while executing the send-notification command',
+ error=e)
+
+
+if __name__ in ('__builtin__', 'builtins', '__main__'):
+ main()
diff --git a/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk.yml b/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk.yml
new file mode 100644
index 000000000000..1d2b917fe510
--- /dev/null
+++ b/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk.yml
@@ -0,0 +1,62 @@
+args:
+- description: The Zoom user ID, JID or email to whom to send the message. You must provide this parameter or the channel_id parameter.
+ name: user
+- description: The ID or the JID of the channel where you intend to send the message. You must provide this parameter or the user parameter.
+ name: channel_id
+- name: channel_name
+ description: The channel name can be provided only if the channel is part of a mirroring incident, instead of the channel JID.
+- description: The message to send to the user or channel.
+ name: message
+ required: true
+- defaultValue: Yes#blue
+ description: The first reply option. The default is "Yes" with a blue button. To change the color of the button, add the pound sign (#) followed by the name of the new color (blue, red, or black). The default color is "blue". For example, "Yes#blue".
+ name: option1
+- description: The second reply option. The default is "No" with a red button. To change the button color, add the pound sign (#) followed by the name of the new color (blue, red, or black). The default color is "red". For example, "No#red".
+ name: option2
+ defaultValue: No#red
+- description: The task to close with the reply. If empty, then no playbook tasks will be closed.
+ name: task
+- auto: PREDEFINED
+ defaultValue: 'false'
+ description: Indicates whether to use one-time entitlement or persistent entitlement.
+ name: persistent
+ predefined:
+ - 'true'
+ - 'false'
+- description: How the user should respond to the question.
+ name: responseType
+ auto: PREDEFINED
+ defaultValue: button
+ predefined:
+ - button
+ - dropdown
+- description: A comma-separated list of additional options in the format of "option#color", for example, "maybe#black". The default color is "black".
+ name: additionalOptions
+- defaultValue: Thank you {user}. you have answered {response} .
+ description: The reply to send to the user. Use the templates {user} and {response} to incorporate these in the reply. (i.e., "Thank you {user}. You have answered {response}.").
+ name: reply
+- defaultValue: 1 day
+ description: Time until the question expires. For example - 1 day. When it expires, a default response is sent.
+ name: lifetime
+- description: Default response in case the question expires.
+ name: defaultResponse
+ defaultValue: NoResponse
+comment: Sends a message (question) to either user (in a direct message) or to a channel. The message includes predefined reply options. The response can also close a task (might be conditional) in a playbook.
+commonfields:
+ id: ZoomAsk
+ version: -1
+enabled: true
+name: ZoomAsk
+script: '-'
+subtype: python3
+tags:
+- zoom
+timeout: '0'
+type: python
+dockerimage: demisto/fastapi:1.0.0.79757
+tests:
+- no test - Untestable
+dependson:
+ must:
+ - Zoom|||send-notification
+fromversion: 5.0.0
diff --git a/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk_test.py b/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk_test.py
new file mode 100644
index 000000000000..38fe96cdf9b7
--- /dev/null
+++ b/Packs/Zoom/Scripts/ZoomAsk/ZoomAsk_test.py
@@ -0,0 +1,116 @@
+import demistomock as demisto
+from ZoomAsk import generate_json, main
+import dateparser
+from CommonServerPython import entryTypes
+import datetime
+
+
+def test_generate_json_button():
+ text = "Test Text"
+ options = ["Option1#blue", "Option2#red"]
+ response_type = "button"
+ result = generate_json(text, options, response_type)
+
+ expected_result = {
+ "head": {
+ "type": "message",
+ "text": text
+ },
+ "body": [
+ {
+ "type": "actions",
+ "items": [
+ {
+ "value": "Option1",
+ "style": "Primary",
+ "text": "Option1"
+ },
+ {
+ "value": "Option2",
+ "style": "Danger",
+ "text": "Option2"
+ }
+ ]
+ }
+ ]
+ }
+
+ assert result == expected_result
+
+
+def test_generate_json_dropdown():
+ text = "Test Text"
+ options = ["Option1#blue", "Option2#red"]
+ response_type = "dropdown"
+ result = generate_json(text, options, response_type)
+
+ expected_result = {
+ "body": [
+ {
+ "select_items": [
+ {
+ "value": "Option1",
+ "text": "Option1"
+ },
+ {
+ "value": "Option2",
+ "text": "Option2"
+ }
+ ],
+ "text": text,
+ "selected_item": {
+ "value": "Option1"
+ },
+ "type": "select"
+ }
+ ]
+ }
+
+ assert result == expected_result
+
+
+def execute_command(command, args):
+ if command == 'addEntitlement':
+ return [{
+ 'Type': entryTypes['note'],
+ 'Contents': '4404dae8-2d45-46bd-85fa-64779c12abe8'
+ }]
+
+ return []
+
+
+def test_main(mocker):
+
+ mocker.patch.object(demisto, 'executeCommand', side_effect=execute_command)
+ mocker.patch.object(demisto, 'investigation', return_value={'id': '22'})
+ mocker.patch.object(demisto, 'args', return_value={
+ 'persistent': 'true',
+ 'option1': 'Option1',
+ 'option2': 'Option2',
+ 'additionalOptions': '',
+ 'reply': 'Test Reply',
+ 'responseType': 'button',
+ 'lifetime': '1 day',
+ 'defaultResponse': 'Default Response',
+ 'user': 'test@example.com',
+ 'message': 'Test Message',
+ 'channel_name': None,
+ 'channel_id': None,
+ 'task': None
+ })
+ mocker.patch.object(demisto, 'results')
+ mocker.patch.object(dateparser, 'parse', return_value=datetime.datetime(2019, 9, 26, 18, 38, 25))
+
+ # Call the main function
+ main()
+
+ call_args = demisto.executeCommand.call_args[0]
+ expected_args = {
+ 'ignoreAddURL': 'true',
+ 'message':
+ '{"blocks": "{\\"head\\": {\\"type\\": \\"message\\", \\"text\\": \\"Test Message\\"}, \\"body\\": [{\\"type\\": \\"actions\\", \\"items\\": [{\\"value\\": \\"Option1\\", \\"style\\": \\"Default\\", \\"text\\": \\"Option1\\"}, {\\"value\\": \\"Option2\\", \\"style\\": \\"Default\\", \\"text\\": \\"Option2\\"}]}]}", "entitlement": "4404dae8-2d45-46bd-85fa-64779c12abe8@22", "reply": "Test Reply", "expiry": "2019-09-26 18:38:25", "default_response": "Default Response"}', # noqa: E501
+ 'to': 'test@example.com',
+ 'zoom_ask': 'true'
+ }
+
+ assert call_args[1] == expected_args
diff --git a/Packs/Zoom/pack_metadata.json b/Packs/Zoom/pack_metadata.json
index b7eacea21bd8..c09791f92a7f 100644
--- a/Packs/Zoom/pack_metadata.json
+++ b/Packs/Zoom/pack_metadata.json
@@ -2,7 +2,7 @@
"name": "Zoom",
"description": "Use the Zoom integration manage your Zoom users and meetings",
"support": "xsoar",
- "currentVersion": "1.5.8",
+ "currentVersion": "1.6.0",
"author": "Cortex XSOAR",
"url": "https://www.paloaltonetworks.com/cortex",
"email": "",