From 05c93520dcad3e14cefdea35a3930da78bda1e78 Mon Sep 17 00:00:00 2001 From: wallrony Date: Mon, 29 Nov 2021 16:56:23 -0300 Subject: [PATCH 1/3] Add slack classic bot platform (#169) Co-authored-by: vassalo --- Dockerfile | 1 + config.py | 21 ++++++++++++----- docs/CONFIGURE_ACCESSBOT.md | 4 ++++ docs/CONFIGURE_LOCAL_ENV.md | 34 ++++++++++++++++++++++++---- docs/CONFIGURE_SLACK_CLASSIC.md | 31 +++++++++++++++++++++++++ ms-teams/dev/start.sh | 2 +- requirements/common.txt | 3 ++- set-env.example.env | 40 +++++++++++++++++++++++++++++++++ 8 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 docs/CONFIGURE_SLACK_CLASSIC.md create mode 100644 set-env.example.env diff --git a/Dockerfile b/Dockerfile index 91d72c2..4b20107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ COPY config.py . COPY errbot-slack-bolt-backend ./errbot-slack-bolt-backend COPY errbot-backend-botframework ./errbot-backend-botframework +RUN rm -rf plugins/* RUN mkdir -p plugins/sdm COPY plugins/sdm ./plugins/sdm/ diff --git a/config.py b/config.py index d64eb3b..f67aa2e 100644 --- a/config.py +++ b/config.py @@ -16,24 +16,35 @@ def get_access_controls(): } def get_bot_identity(): - if os.getenv('SDM_BOT_PLATFORM') == 'ms-teams': + platform = os.getenv('SDM_BOT_PLATFORM') + if platform == 'ms-teams': return { "appId": os.getenv("AZURE_APP_ID"), "appPassword": os.getenv("AZURE_APP_PASSWORD") } + elif platform == 'slack-classic': + return { + 'token': os.getenv("SLACK_TOKEN") + } return { - "app_token": os.environ["SLACK_APP_TOKEN"], - "bot_token": os.environ["SLACK_BOT_TOKEN"], + "app_token": os.getenv("SLACK_APP_TOKEN"), + "bot_token": os.getenv("SLACK_BOT_TOKEN"), } def get_backend(): - if os.getenv('SDM_BOT_PLATFORM') == 'ms-teams': + platform = os.getenv('SDM_BOT_PLATFORM') + if platform == 'ms-teams': return 'BotFramework' + elif platform == 'slack-classic': + return 'Slack' return 'SlackBolt' def get_bot_extra_backend_dir(): - if os.getenv('SDM_BOT_PLATFORM') == 'ms-teams': + platform = os.getenv('SDM_BOT_PLATFORM') + if platform == 'ms-teams': return 'errbot-backend-botframework' + elif platform == 'slack-classic': + return None return 'errbot-slack-bolt-backend/errbot_slack_bolt_backend' diff --git a/docs/CONFIGURE_ACCESSBOT.md b/docs/CONFIGURE_ACCESSBOT.md index 01d0664..0a16d89 100644 --- a/docs/CONFIGURE_ACCESSBOT.md +++ b/docs/CONFIGURE_ACCESSBOT.md @@ -9,6 +9,9 @@ There are a number of variables you can use for configuring AccessBot. * **SDM_API_ACCESS_KEY**. SDM API Access Key * **SDM_API_SECRET_KEY**. SDM API Access Key Secret +_Note_: when SDM_BOT_PLATFORM is 'slack-classic', you need to set the following variable instead of **SLACK_APP_TOKEN** and **SLACK_BOT_TOKEN**: + - **SLACK_TOKEN**. Slack Bot User OAuth Token for Classic Slack bot version + ## Internal configuration * **LOG_LEVEL**. Logging level. Default = INFO * **SDM_DOCKERIZED**. Logging type. Default = true (_when using docker_), meaning logs go to STDOUT @@ -48,6 +51,7 @@ See image below for more information: `System Preferences > Keyboard > Text > Uncheck "Use smart quotes and dashes`. The `config` command fails to understand quotes as unicode characters. ### Using Tags +A snippet that might help: #### Allow Resource ``` diff --git a/docs/CONFIGURE_LOCAL_ENV.md b/docs/CONFIGURE_LOCAL_ENV.md index 4558915..18ebded 100644 --- a/docs/CONFIGURE_LOCAL_ENV.md +++ b/docs/CONFIGURE_LOCAL_ENV.md @@ -11,20 +11,46 @@ pip install -r requirements/dev.txt ## Variables configuration ``` -export SLACK_APP_TOKEN=slack-app-token -export SLACK_BOT_TOKEN=slack-bot-token export SDM_API_ACCESS_KEY=api-access-key export SDM_API_SECRET_KEY=api-secret-key export SDM_ADMINS=@admin1 # if multiple, use: @admin1 @admin2 ``` -See [Configure Slack](CONFIGURE_SLACK.md) and [Configure SDM](CONFIGURE_SDM.md) +## BOT PLATFORM variables configuration + +See the subsessions about SDM_BOT_PLATFORM specific variables: + +### SDM_BOT_PLATFORM is `slack` +``` +export SLACK_APP_TOKEN=slack-app-token +export SLACK_BOT_TOKEN=slack-bot-token +``` + +See [Configure Slack](CONFIGURE_SLACK.md) + +### SDM_BOT_PLATFORM is `slack-classic` +``` +export SLACK_TOKEN=slack-token +``` + +See [Configure Slack Classic Bot](CONFIGURE_SLACK_CLASSIC.md) + +### SDM_BOT_PLATFORM is `ms-teams`: +``` +export AZURE_APP_ID=app-id +export AZURE_APP_PASSWORD=app-password +``` + +See [Configure Microsoft Teams](CONFIGURE_MS_TEAMS.md) + +--- + +Before initialize errbot, you also need to [Configure SDM](CONFIGURE_SDM.md). ## Initialize errbot ``` mv config.py config.py.back errbot --init -pip install errbot[slack] mv config.py.back config.py ``` diff --git a/docs/CONFIGURE_SLACK_CLASSIC.md b/docs/CONFIGURE_SLACK_CLASSIC.md new file mode 100644 index 0000000..18abecf --- /dev/null +++ b/docs/CONFIGURE_SLACK_CLASSIC.md @@ -0,0 +1,31 @@ +# Configure Slack + +In order to configure AccessBot integration with Slack follow the next steps: + +1. Go to https://api.slack.com/apps?new_classic_app=1 and create a classic app + +![image](https://user-images.githubusercontent.com/313803/115708663-936d2380-a370-11eb-94d2-b5edb1596af7.png) + +2. Go to OAuth & Permissions and add bot scope in the Scopes + +![image](https://user-images.githubusercontent.com/313803/115709326-653c1380-a371-11eb-9346-f2fa81c7fd24.png) + +IMPORTANT: The reason why you need a classic app and the bot scope, is because the current AccessBot implementation uses the RTM API, which is not available +when updating to the new bot scopes. + +4. Go to App Home + +![image](https://user-images.githubusercontent.com/313803/115710249-6cafec80-a372-11eb-9071-bad38cf0d4bf.png) + +5. Click Add Legacy Bot User and set its name + +![image](https://user-images.githubusercontent.com/313803/115710432-a2ed6c00-a372-11eb-8fda-b8ef9c874e49.png) + +6. Go to Install App + +![image](https://user-images.githubusercontent.com/313803/115710557-c6181b80-a372-11eb-95dd-72927c81e53a.png) + + +**Use "Bot User OAuth Token" for your _SLACK_TOKEN_ variable** + +_Original instructions from [this thread](https://github.com/slackapi/python-slack-sdk/issues/609#issuecomment-6398872129)_ diff --git a/ms-teams/dev/start.sh b/ms-teams/dev/start.sh index ab6a7c4..70e7aff 100755 --- a/ms-teams/dev/start.sh +++ b/ms-teams/dev/start.sh @@ -2,7 +2,7 @@ errbot & pid[0]=$! -ssh -N -R 3141:localhost:3141 -i $LOG_EXPORT_CONTAINER_SSH_CREDENTIALS $LOG_EXPORT_CONTAINER_SSH_DESTINATION & +ssh -N -R 3141:localhost:3141 -i $ACCESSBOT_WEBHOOK_SSH_CREDENTIALS $ACCESSBOT_WEBHOOK_SSH_DESTINATION & pid[1]=$! trap "kill ${pid[0]} ${pid[1]}; exit 1" INT wait diff --git a/requirements/common.txt b/requirements/common.txt index 58a7e1b..74a1fdf 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -2,8 +2,9 @@ strongdm datetime errbot +slackclient slack-bolt shortuuid fuzzywuzzy python-Levenshtein - +errbot[slack] diff --git a/set-env.example.env b/set-env.example.env new file mode 100644 index 0000000..77bd753 --- /dev/null +++ b/set-env.example.env @@ -0,0 +1,40 @@ +# Create your set-env.sh or use the env vars in docker based on the examples +# below. + +# Ps.: this file only has examples for the required variables. If you need to +# include some optional var, refer to doc files in the docs folder. + +# ------------------------------------------------------------------------------ +# | GENERAL ENV VARS | +# ------------------------------------------------------------------------------ +# These vars are required for any SDM_BOT_PLAFORM. + +SDM_BOT_PLATFORM=slack # possible values: slack, slack-classic, ms-teams +SDM_API_ACCESS_KEY= +SDM_API_SECRET_KEY= + +# ------------------------------------------------------------------------------ +# | SLACK BOLT ENV VARS | +# ------------------------------------------------------------------------------ +# You need to use the following vars when SDM_BOT_PLATFORM var is "slack": + +# SLACK_APP_TOKEN= +# SLACK_BOT_TOKEN= +# SDM_ADMINS=@nickname + +# ------------------------------------------------------------------------------ +# | SLACK CLASSIC ENV VARS | +# ------------------------------------------------------------------------------ +# You need to use the following vars when SDM_BOT_PLATFORM var is "slack-classic": + +# SLACK_TOKEN= +# SDM_ADMINS=@nickname + +# ------------------------------------------------------------------------------ +# | MS-TEAMS ENV VARS | +# ------------------------------------------------------------------------------ +# You need to use the following vars when SDM_BOT_PLATFORM var is "ms-teams": + +# SDM_ADMINS=user@email.com +# AZURE_APP_ID= +# AZURE_APP_PASSWORD= From bfacf3ed17ce751ebbcf993ac6edbf36c12cb16d Mon Sep 17 00:00:00 2001 From: wallrony Date: Tue, 30 Nov 2021 08:38:38 -0300 Subject: [PATCH 2/3] Fix poller tests condition Co-authored-by: vassalo --- plugins/sdm/lib/helper/poller_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/sdm/lib/helper/poller_helper.py b/plugins/sdm/lib/helper/poller_helper.py index 6946f53..6577a23 100644 --- a/plugins/sdm/lib/helper/poller_helper.py +++ b/plugins/sdm/lib/helper/poller_helper.py @@ -9,7 +9,7 @@ def stale_grant_requests_cleaner(self): for request_id in self.__bot.get_grant_request_ids(): grant_request = self.__bot.get_grant_request(request_id) elapsed_time = time.time() - grant_request['timestamp'] - if elapsed_time > self.__bot.config['ADMIN_TIMEOUT']: + if elapsed_time >= self.__bot.config['ADMIN_TIMEOUT']: self.__bot.log.info("##SDM## Cleaning grant requests, stale request_id = %s", request_id) self.__notify_grant_request_denied(grant_request) self.__bot.remove_grant_request(request_id) From 7473868d134be05a20c90d3112b7ebb2affc51e2 Mon Sep 17 00:00:00 2001 From: Rodolfo Campos Date: Wed, 1 Dec 2021 17:38:54 +0100 Subject: [PATCH 3/3] Update docker-compose.yaml and docs (#169) --- Dockerfile | 3 +-- docker-compose.yaml | 16 +++++----------- docs/CONFIGURE_ACCESSBOT.md | 16 +++++++++++----- docs/CONFIGURE_ALTERNATIVE_EMAILS.md | 2 +- docs/CONFIGURE_LOCAL_ENV.md | 10 +++++----- set-env.example.env => env-file.example | 12 +++++++----- plugins/sdm/accessbot.py | 4 ++-- plugins/sdm/lib/helper/base_grant_helper.py | 7 +++---- plugins/sdm/lib/platform/base_platform.py | 2 +- plugins/sdm/lib/platform/ms_teams_platform.py | 7 ++----- plugins/sdm/lib/platform/slack_platform.py | 7 ++----- 11 files changed, 40 insertions(+), 46 deletions(-) rename set-env.example.env => env-file.example (79%) diff --git a/Dockerfile b/Dockerfile index 4b20107..9a63433 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,11 @@ RUN pip install \ -r requirements.txt RUN pip install errbot[slack] -COPY data ./data COPY config.py . COPY errbot-slack-bolt-backend ./errbot-slack-bolt-backend COPY errbot-backend-botframework ./errbot-backend-botframework -RUN rm -rf plugins/* +RUN mkdir ./data RUN mkdir -p plugins/sdm COPY plugins/sdm ./plugins/sdm/ diff --git a/docker-compose.yaml b/docker-compose.yaml index dd5306e..49fb85b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,15 +2,9 @@ version: "3.9" services: accessbot: image: public.ecr.aws/strongdm/accessbot:latest - environment: - # IMPORTANT: Do not enclose values in double or single quotes + env_file: + # You could use env-file.example as a reference + - env-file + ports: + - 3141:3141 - # Required variables - - SLACK_APP_TOKEN=slack-app-token - - SLACK_BOT_TOKEN=slack-bot-token - - SDM_ADMINS=@slack-handle1 @slack-handle2 # use space for configuring multiple slack handles - - SDM_API_ACCESS_KEY=sdm-api-acess-key - - SDM_API_SECRET_KEY=sdm-api-secret-key - - # Optional variables - # See: docs/CONFIGURE_ACCESSBOT.md diff --git a/docs/CONFIGURE_ACCESSBOT.md b/docs/CONFIGURE_ACCESSBOT.md index 0a16d89..831b025 100644 --- a/docs/CONFIGURE_ACCESSBOT.md +++ b/docs/CONFIGURE_ACCESSBOT.md @@ -3,14 +3,20 @@ There are a number of variables you can use for configuring AccessBot. ## Required configuration -* **SLACK_APP_TOKEN**. Slack App-Level Token -* **SLACK_BOT_TOKEN**. Slack Bot User OAuth Token -* **SDM_ADMINS**. List of Slack admins, format: `@usernick`. Although it's not required, these users are often SDM admins too. You could use `whoami` for getting user nicks. +* **SDM_ADMINS**. List of Slack admins, format: `@usernick`. Although it's not required, these users are often SDM admins too. For getting user nicks, you could use the command `whoami` or the [tools/get-slack-handle.py](../tools/get-slack-handle.py) script. * **SDM_API_ACCESS_KEY**. SDM API Access Key * **SDM_API_SECRET_KEY**. SDM API Access Key Secret -_Note_: when SDM_BOT_PLATFORM is 'slack-classic', you need to set the following variable instead of **SLACK_APP_TOKEN** and **SLACK_BOT_TOKEN**: - - **SLACK_TOKEN**. Slack Bot User OAuth Token for Classic Slack bot version +### Slack (SDM_BOT_PLATFORM='slack' / default) +* **SLACK_APP_TOKEN**. Slack App-Level Token +* **SLACK_BOT_TOKEN**. Slack Bot User OAuth Token + +### Slack Classic (SDM_BOT_PLATFORM='slack-classic') +* **SLACK_TOKEN**. Slack Bot User OAuth Token for Classic Slack bot version + +### MS Teams (SDM_BOT_PLATFORM='ms-teams') +* **AZURE_APP_ID**. Set to the **Microsoft App ID** +* **AZURE_APP_PASSWORD**. Set to the **Secret Value** ## Internal configuration * **LOG_LEVEL**. Logging level. Default = INFO diff --git a/docs/CONFIGURE_ALTERNATIVE_EMAILS.md b/docs/CONFIGURE_ALTERNATIVE_EMAILS.md index 64b3dc0..981d20d 100644 --- a/docs/CONFIGURE_ALTERNATIVE_EMAILS.md +++ b/docs/CONFIGURE_ALTERNATIVE_EMAILS.md @@ -2,7 +2,7 @@ You can make access requests using alternative emails. This functionality is specially helpful when you need to make access requests using a different email address than the one you have configured in your Slack Profile. -**_Custom profile fields are only available for Slack Business+ workspaces_** +**_Custom profile fields are only available for Slack Business+ workspaces and Bolt API_** Follow these steps to configure in your Slack Workspace: diff --git a/docs/CONFIGURE_LOCAL_ENV.md b/docs/CONFIGURE_LOCAL_ENV.md index 18ebded..fe775da 100644 --- a/docs/CONFIGURE_LOCAL_ENV.md +++ b/docs/CONFIGURE_LOCAL_ENV.md @@ -16,11 +16,11 @@ export SDM_API_SECRET_KEY=api-secret-key export SDM_ADMINS=@admin1 # if multiple, use: @admin1 @admin2 ``` -## BOT PLATFORM variables configuration +### BOT PLATFORM variables configuration See the subsessions about SDM_BOT_PLATFORM specific variables: -### SDM_BOT_PLATFORM is `slack` +#### SDM_BOT_PLATFORM is `slack` ``` export SLACK_APP_TOKEN=slack-app-token export SLACK_BOT_TOKEN=slack-bot-token @@ -28,14 +28,14 @@ export SLACK_BOT_TOKEN=slack-bot-token See [Configure Slack](CONFIGURE_SLACK.md) -### SDM_BOT_PLATFORM is `slack-classic` +#### SDM_BOT_PLATFORM is `slack-classic` ``` export SLACK_TOKEN=slack-token ``` See [Configure Slack Classic Bot](CONFIGURE_SLACK_CLASSIC.md) -### SDM_BOT_PLATFORM is `ms-teams`: +#### SDM_BOT_PLATFORM is `ms-teams`: ``` export AZURE_APP_ID=app-id export AZURE_APP_PASSWORD=app-password @@ -50,7 +50,7 @@ Before initialize errbot, you also need to [Configure SDM](CONFIGURE_SDM.md). ## Initialize errbot ``` mv config.py config.py.back -errbot --init +pip install errbot[slack] mv config.py.back config.py ``` diff --git a/set-env.example.env b/env-file.example similarity index 79% rename from set-env.example.env rename to env-file.example index 77bd753..ac379ad 100644 --- a/set-env.example.env +++ b/env-file.example @@ -1,8 +1,5 @@ -# Create your set-env.sh or use the env vars in docker based on the examples -# below. - -# Ps.: this file only has examples for the required variables. If you need to -# include some optional var, refer to doc files in the docs folder. +# You can copy this file as "env-file" for your docker-compose +# IMPORTANT: Do not enclose values in double or single quotes # ------------------------------------------------------------------------------ # | GENERAL ENV VARS | @@ -38,3 +35,8 @@ SDM_API_SECRET_KEY= # SDM_ADMINS=user@email.com # AZURE_APP_ID= # AZURE_APP_PASSWORD= + +# ------------------------------------------------------------------------------ +# | OPTIONAL VARS | +# ------------------------------------------------------------------------------ +# See: docs/CONFIGURE_ACCESSBOT.md diff --git a/plugins/sdm/accessbot.py b/plugins/sdm/accessbot.py index a4cf1c4..cd03d16 100644 --- a/plugins/sdm/accessbot.py +++ b/plugins/sdm/accessbot.py @@ -238,8 +238,8 @@ def get_sdm_email_from_profile(self, sender, email_field): def clean_up_message(self, message): return self._platform.clean_up_message(message) - def format_access_request_params(self, resource_name, sender_nick, request_id): - return self._platform.format_access_request_params(resource_name, sender_nick, request_id) + def format_access_request_params(self, resource_name, sender_nick): + return self._platform.format_access_request_params(resource_name, sender_nick) def format_strikethrough(self, text): return self._platform.format_strikethrough(text) diff --git a/plugins/sdm/lib/helper/base_grant_helper.py b/plugins/sdm/lib/helper/base_grant_helper.py index 1393cd9..a7ae952 100644 --- a/plugins/sdm/lib/helper/base_grant_helper.py +++ b/plugins/sdm/lib/helper/base_grant_helper.py @@ -66,7 +66,6 @@ def __grant_access(self, message, sdm_object, sdm_account, execution_id, request self.__bot.log.info("##SDM## %s GrantHelper.__grant_%s sender_nick: %s sender_email: %s", execution_id, self.__grant_type, sender_nick, sender_email) self.__enter_grant_request(message, sdm_object, sdm_account, self.__grant_type, request_id) if not self.__needs_auto_approve(sdm_object) or self.__reached_max_auto_approve_uses(message.frm.person): - # TODO: ADD EXTRAS yield from self.__notify_access_request_entered(sender_nick, sdm_object.name, request_id, message) self.__bot.log.debug("##SDM## %s GrantHelper.__grant_%s needs manual approval", execution_id, self.__grant_type) return @@ -90,9 +89,9 @@ def __reached_max_auto_approve_uses(self, requester_id): def __notify_access_request_entered(self, sender_nick, resource_name, request_id, message): team_admins = ", ".join(self.__bot.get_admins()) operation_desc = self.get_operation_desc() - resource_name, sender_nick, request_id = self.__bot.format_access_request_params(resource_name, sender_nick, request_id) - yield f"Thanks {sender_nick}, that is a valid request. Let me check with the team admins: {team_admins}\nYour request id is {request_id}" - self.__notify_admins(f"Hey I have an {operation_desc} request from USER {sender_nick} for {self.__grant_type.name} {resource_name}! To approve, enter: **yes** {request_id}", message) + formatted_resource_name, formatted_sender_nick = self.__bot.format_access_request_params(resource_name, sender_nick) + yield f"Thanks {formatted_sender_nick}, that is a valid request. Let me check with the team admins: {team_admins}\nYour request id is **{request_id}**" + self.__notify_admins(f"Hey I have an {operation_desc} request from USER {formatted_sender_nick} for {self.__grant_type.name} {formatted_resource_name}! To approve, enter: **yes {request_id}**", message) def __notify_admins(self, text, message): admins_channel = self.__bot.config['ADMINS_CHANNEL'] diff --git a/plugins/sdm/lib/platform/base_platform.py b/plugins/sdm/lib/platform/base_platform.py index 68c091a..78c48b8 100644 --- a/plugins/sdm/lib/platform/base_platform.py +++ b/plugins/sdm/lib/platform/base_platform.py @@ -50,7 +50,7 @@ def clean_up_message(self, text): pass @abstractmethod - def format_access_request_params(self, resource_name, sender_nick, request_id): + def format_access_request_params(self, resource_name, sender_nick): pass @abstractmethod diff --git a/plugins/sdm/lib/platform/ms_teams_platform.py b/plugins/sdm/lib/platform/ms_teams_platform.py index e27137d..c897aa5 100644 --- a/plugins/sdm/lib/platform/ms_teams_platform.py +++ b/plugins/sdm/lib/platform/ms_teams_platform.py @@ -45,11 +45,8 @@ def get_user_nick(self, approver): def clean_up_message(self, text): return re.sub(r'.+', '', text).strip() - def format_access_request_params(self, resource_name, sender_nick, request_id): - resource_name = f'**{resource_name}**' - sender_nick = f'**{sender_nick}**' - request_id = f'**{request_id}**' - return resource_name, sender_nick, request_id + def format_access_request_params(self, resource_name, sender_nick): + return f'**{resource_name}**', f'**{sender_nick}**' def format_strikethrough(self, text): return r"~~" + text + r"~~" diff --git a/plugins/sdm/lib/platform/slack_platform.py b/plugins/sdm/lib/platform/slack_platform.py index e6a651e..1750d0d 100644 --- a/plugins/sdm/lib/platform/slack_platform.py +++ b/plugins/sdm/lib/platform/slack_platform.py @@ -40,11 +40,8 @@ def get_user_nick(self, approver): def clean_up_message(self, text): return text - def format_access_request_params(self, resource_name, sender_nick, request_id): - resource_name = r"\`" + resource_name + r"\`" - sender_nick = r"\`" + sender_nick + r"\`" - request_id = r"\`" + request_id + r"\`" - return resource_name, sender_nick, request_id + def format_access_request_params(self, resource_name, sender_nick): + return r"\`" + resource_name + r"\`", r"\`" + sender_nick + r"\`" def format_strikethrough(self, text): return r"~" + text + r"~"