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": "",