├── lambda-code ├── slack_sdk │ ├── py.typed │ ├── rtm │ │ └── v2 │ │ │ └── __init__.py │ ├── scim │ │ ├── async_client.py │ │ ├── v1 │ │ │ ├── default_arg.py │ │ │ ├── __init__.py │ │ │ ├── types.py │ │ │ ├── group.py │ │ │ ├── internal_utils.py │ │ │ └── response.py │ │ └── __init__.py │ ├── socket_mode │ │ ├── builtin │ │ │ ├── __init__.py │ │ │ └── frame_header.py │ │ ├── __init__.py │ │ ├── listeners.py │ │ ├── async_listeners.py │ │ ├── response.py │ │ ├── interval_runner.py │ │ ├── request.py │ │ ├── async_client.py │ │ ├── client.py │ │ └── websockets │ │ │ └── __init__.py │ ├── audit_logs │ │ ├── async_client.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── response.py │ │ │ └── internal_utils.py │ │ └── __init__.py │ ├── version.py │ ├── oauth │ │ ├── installation_store │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── bot.py │ │ │ │ └── installation.py │ │ │ ├── __init__.py │ │ │ ├── installation_store.py │ │ │ ├── async_installation_store.py │ │ │ ├── cacheable_installation_store.py │ │ │ ├── async_cacheable_installation_store.py │ │ │ └── file │ │ │ │ └── __init__.py │ │ ├── state_store │ │ │ ├── __init__.py │ │ │ ├── state_store.py │ │ │ ├── async_state_store.py │ │ │ ├── file │ │ │ │ └── __init__.py │ │ │ ├── amazon_s3 │ │ │ │ └── __init__.py │ │ │ ├── sqlalchemy │ │ │ │ └── __init__.py │ │ │ └── sqlite3 │ │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── authorize_url_generator │ │ │ └── __init__.py │ │ ├── state_utils │ │ │ └── __init__.py │ │ └── redirect_uri_page_renderer │ │ │ └── __init__.py │ ├── web │ │ ├── __init__.py │ │ ├── deprecation.py │ │ ├── async_internal_utils.py │ │ ├── slack_response.py │ │ ├── async_base_client.py │ │ ├── async_slack_response.py │ │ └── legacy_slack_response.py │ ├── webhook │ │ ├── __init__.py │ │ ├── webhook_response.py │ │ ├── internal_utils.py │ │ ├── async_client.py │ │ └── client.py │ ├── proxy_env_variable_loader.py │ ├── models │ │ ├── dialoags.py │ │ ├── __init__.py │ │ ├── blocks │ │ │ └── __init__.py │ │ ├── messages │ │ │ ├── __init__.py │ │ │ └── message.py │ │ └── basic_objects.py │ ├── aiohttp_version_checker.py │ ├── __init__.py │ ├── errors │ │ └── __init__.py │ └── signature │ │ └── __init__.py ├── lambda_function_updated.py └── lambda_function.py ├── application-configurations.json ├── images ├── architecture-diagram.png ├── architecture-diagram-v1.1.png └── slack-notification-sample.png ├── LICENSE ├── test-cost-anomaly-event.json ├── appspec.yml ├── README.md ├── MODERNIZATION-NOTES-2025.md └── CRITICAL-FIXES.patch /lambda-code/slack_sdk/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/rtm/v2/__init__.py: -------------------------------------------------------------------------------- 1 | from slack_sdk.rtm_v2 import RTMClient # noqa 2 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/scim/async_client.py: -------------------------------------------------------------------------------- 1 | from .v1.async_client import AsyncSCIMClient # noqa 2 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/builtin/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import SocketModeClient # noqa 2 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/audit_logs/async_client.py: -------------------------------------------------------------------------------- 1 | from .v1.async_client import AsyncAuditLogsClient # noqa 2 | -------------------------------------------------------------------------------- /application-configurations.json: -------------------------------------------------------------------------------- 1 | { 2 | "feature-flags": { 3 | "displayAccountName": false 4 | } 5 | } -------------------------------------------------------------------------------- /lambda-code/slack_sdk/scim/v1/default_arg.py: -------------------------------------------------------------------------------- 1 | class DefaultArg: 2 | pass 3 | 4 | 5 | NotGiven = DefaultArg() 6 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/version.py: -------------------------------------------------------------------------------- 1 | """Check the latest version at https://pypi.org/project/slack-sdk/""" 2 | __version__ = "3.5.0" 3 | -------------------------------------------------------------------------------- /images/architecture-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ighanim/aws-cost-anomaly-detection-slack-integration/HEAD/images/architecture-diagram.png -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import Bot # noqa 2 | from .installation import Installation # noqa 3 | -------------------------------------------------------------------------------- /images/architecture-diagram-v1.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ighanim/aws-cost-anomaly-detection-slack-integration/HEAD/images/architecture-diagram-v1.1.png -------------------------------------------------------------------------------- /images/slack-notification-sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ighanim/aws-cost-anomaly-detection-slack-integration/HEAD/images/slack-notification-sample.png -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/__init__.py: -------------------------------------------------------------------------------- 1 | from .file import FileInstallationStore # noqa 2 | from .installation_store import InstallationStore # noqa 3 | from .models import Bot, Installation # noqa 4 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/audit_logs/v1/__init__.py: -------------------------------------------------------------------------------- 1 | """Audit Logs API is a set of APIs for monitoring what’s happening in your Enterprise Grid organization. 2 | 3 | Refer to https://slack.dev/python-slack-sdk/audit-logs/ for details. 4 | """ 5 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/web/__init__.py: -------------------------------------------------------------------------------- 1 | """The Slack Web API allows you to build applications that interact with Slack 2 | in more complex ways than the integrations we provide out of the box.""" 3 | from .client import WebClient # noqa 4 | from .slack_response import SlackResponse # noqa 5 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/state_store/__init__.py: -------------------------------------------------------------------------------- 1 | """OAuth state parameter data store 2 | 3 | Refer to https://slack.dev/python-slack-sdk/oauth/ for details. 4 | """ 5 | # from .amazon_s3_state_store import AmazonS3OAuthStateStore 6 | from .file import FileOAuthStateStore # noqa 7 | from .state_store import OAuthStateStore # noqa 8 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/webhook/__init__.py: -------------------------------------------------------------------------------- 1 | """You can use slack_sdk.webhook.WebhookClient for Incoming Webhooks 2 | and message responses using response_url in payloads. 3 | """ 4 | # from .async_client import AsyncWebhookClient # noqa 5 | from .client import WebhookClient # noqa 6 | from .webhook_response import WebhookResponse # noqa 7 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/audit_logs/__init__.py: -------------------------------------------------------------------------------- 1 | """Audit Logs API is a set of APIs for monitoring what’s happening in your Enterprise Grid organization. 2 | 3 | Refer to https://slack.dev/python-slack-sdk/audit-logs/ for details. 4 | """ 5 | from .v1.client import AuditLogsClient # noqa 6 | from .v1.response import AuditLogsResponse # noqa 7 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/scim/v1/__init__.py: -------------------------------------------------------------------------------- 1 | """SCIM API is a set of APIs for provisioning and managing user accounts and groups. 2 | SCIM is used by Single Sign-On (SSO) services and identity providers to manage people across a variety of tools, 3 | including Slack. 4 | 5 | Refer to https://slack.dev/python-slack-sdk/scim/ for details. 6 | """ 7 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/__init__.py: -------------------------------------------------------------------------------- 1 | """Socket Mode is a method of connecting your app to Slack’s APIs using WebSockets instead of HTTP. 2 | You can use slack_sdk.socket_mode.SocketModeClient for managing Socket Mode connections 3 | and performing interactions with Slack. 4 | 5 | https://api.slack.com/apis/connections/socket 6 | """ 7 | from .builtin import SocketModeClient # noqa 8 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/webhook/webhook_response.py: -------------------------------------------------------------------------------- 1 | class WebhookResponse: 2 | def __init__( 3 | self, 4 | *, 5 | url: str, 6 | status_code: int, 7 | body: str, 8 | headers: dict, 9 | ): 10 | self.api_url = url 11 | self.status_code = status_code 12 | self.body = body 13 | self.headers = headers 14 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/state_store/state_store.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | 3 | 4 | class OAuthStateStore: 5 | @property 6 | def logger(self) -> Logger: 7 | raise NotImplementedError() 8 | 9 | def issue(self, *args, **kwargs) -> str: 10 | raise NotImplementedError() 11 | 12 | def consume(self, state: str) -> bool: 13 | raise NotImplementedError() 14 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/state_store/async_state_store.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | 3 | 4 | class AsyncOAuthStateStore: 5 | @property 6 | def logger(self) -> Logger: 7 | raise NotImplementedError() 8 | 9 | async def async_issue(self, *args, **kwargs) -> str: 10 | raise NotImplementedError() 11 | 12 | async def async_consume(self, state: str) -> bool: 13 | raise NotImplementedError() 14 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/__init__.py: -------------------------------------------------------------------------------- 1 | """Modules for implementing the Slack OAuth flow 2 | 3 | https://slack.dev/python-slack-sdk/oauth/ 4 | """ 5 | from .authorize_url_generator import AuthorizeUrlGenerator # noqa 6 | from .installation_store import InstallationStore # noqa 7 | from .redirect_uri_page_renderer import RedirectUriPageRenderer # noqa 8 | from .state_store import OAuthStateStore # noqa 9 | from .state_utils import OAuthStateUtils # noqa 10 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/listeners.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from slack_sdk.socket_mode.request import SocketModeRequest 4 | 5 | 6 | class WebSocketMessageListener: 7 | def __call__( 8 | client: "BaseSocketModeClient", # noqa: F821 9 | message: dict, 10 | raw_message: Optional[str] = None, 11 | ): # noqa: F821 12 | raise NotImplementedError() 13 | 14 | 15 | class SocketModeRequestListener: 16 | def __call__( 17 | client: "BaseSocketModeClient", request: SocketModeRequest # noqa: F821 18 | ): # noqa: F821 19 | raise NotImplementedError() 20 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/scim/__init__.py: -------------------------------------------------------------------------------- 1 | """SCIM API is a set of APIs for provisioning and managing user accounts and groups. 2 | SCIM is used by Single Sign-On (SSO) services and identity providers to manage people across a variety of tools, 3 | including Slack. 4 | 5 | Refer to https://slack.dev/python-slack-sdk/scim/ for details. 6 | """ 7 | from .v1.client import SCIMClient # noqa 8 | from .v1.response import SCIMResponse # noqa 9 | from .v1.response import SearchUsersResponse, ReadUserResponse # noqa 10 | from .v1.response import SearchGroupsResponse, ReadGroupResponse # noqa 11 | from .v1.user import User # noqa 12 | from .v1.group import Group # noqa 13 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/async_listeners.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Callable 2 | 3 | from slack_sdk.socket_mode.request import SocketModeRequest 4 | 5 | 6 | class AsyncWebSocketMessageListener(Callable): 7 | async def __call__( 8 | client: "AsyncBaseSocketModeClient", # noqa: F821 9 | message: dict, 10 | raw_message: Optional[str] = None, 11 | ): # noqa: F821 12 | raise NotImplementedError() 13 | 14 | 15 | class AsyncSocketModeRequestListener(Callable): 16 | async def __call__( 17 | client: "AsyncBaseSocketModeClient", # noqa: F821 18 | request: SocketModeRequest, 19 | ): # noqa: F821 20 | raise NotImplementedError() 21 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/proxy_env_variable_loader.py: -------------------------------------------------------------------------------- 1 | """Internal module for loading proxy-related env variables""" 2 | import logging 3 | import os 4 | from typing import Optional 5 | 6 | _default_logger = logging.getLogger(__name__) 7 | 8 | 9 | def load_http_proxy_from_env(logger: logging.Logger = _default_logger) -> Optional[str]: 10 | proxy_url = ( 11 | os.environ.get("HTTPS_PROXY") 12 | or os.environ.get("https_proxy") 13 | or os.environ.get("HTTP_PROXY") 14 | or os.environ.get("http_proxy") 15 | ) 16 | if proxy_url is not None: 17 | logger.debug( 18 | f"HTTP proxy URL has been loaded from an env variable: {proxy_url}" 19 | ) 20 | return proxy_url 21 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/models/dialoags.py: -------------------------------------------------------------------------------- 1 | from slack_sdk.models.dialogs import AbstractDialogSelector # noqa 2 | from slack_sdk.models.dialogs import DialogChannelSelector # noqa 3 | from slack_sdk.models.dialogs import DialogConversationSelector # noqa 4 | from slack_sdk.models.dialogs import DialogExternalSelector # noqa 5 | from slack_sdk.models.dialogs import DialogStaticSelector # noqa 6 | from slack_sdk.models.dialogs import DialogTextArea # noqa 7 | from slack_sdk.models.dialogs import DialogTextComponent # noqa 8 | from slack_sdk.models.dialogs import DialogTextField # noqa 9 | from slack_sdk.models.dialogs import DialogUserSelector # noqa 10 | from slack_sdk.models.dialogs import TextElementSubtypes # noqa 11 | from slack_sdk.models.dialogs import DialogBuilder # noqa 12 | 13 | from slack import deprecation 14 | 15 | deprecation.show_message(__name__, "slack_sdk.models.dialogs") 16 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/scim/v1/types.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union, Dict, Any 2 | 3 | from .default_arg import DefaultArg, NotGiven 4 | from .internal_utils import _to_dict_without_not_given 5 | 6 | 7 | class TypeAndValue: 8 | primary: Union[Optional[bool], DefaultArg] 9 | type: Union[Optional[str], DefaultArg] 10 | value: Union[Optional[str], DefaultArg] 11 | unknown_fields: Dict[str, Any] 12 | 13 | def __init__( 14 | self, 15 | *, 16 | primary: Union[Optional[bool], DefaultArg] = NotGiven, 17 | type: Union[Optional[str], DefaultArg] = NotGiven, 18 | value: Union[Optional[str], DefaultArg] = NotGiven, 19 | **kwargs, 20 | ) -> None: 21 | self.primary = primary 22 | self.type = type 23 | self.value = value 24 | self.unknown_fields = kwargs 25 | 26 | def to_dict(self) -> dict: 27 | return _to_dict_without_not_given(self) 28 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/audit_logs/v1/response.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any 3 | 4 | from slack_sdk.audit_logs.v1.logs import LogsResponse 5 | 6 | 7 | class AuditLogsResponse: 8 | url: str 9 | status_code: int 10 | headers: Dict[str, Any] 11 | raw_body: str 12 | body: Dict[str, Any] 13 | typed_body: LogsResponse 14 | 15 | @property 16 | def typed_body(self) -> LogsResponse: 17 | return LogsResponse(**self.body) 18 | 19 | def __init__( 20 | self, 21 | *, 22 | url: str, 23 | status_code: int, 24 | raw_body: str, 25 | headers: dict, 26 | ): 27 | self.url = url 28 | self.status_code = status_code 29 | self.headers = headers 30 | self.raw_body = raw_body 31 | self.body = ( 32 | json.loads(raw_body) 33 | if raw_body is not None and raw_body.startswith("{") 34 | else None 35 | ) 36 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/response.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional 2 | 3 | from slack_sdk.models import JsonObject 4 | 5 | 6 | class SocketModeResponse: 7 | envelope_id: str 8 | payload: dict 9 | 10 | def __init__( 11 | self, envelope_id: str, payload: Optional[Union[dict, JsonObject, str]] = None 12 | ): 13 | self.envelope_id = envelope_id 14 | 15 | if payload is None: 16 | self.payload = None 17 | elif isinstance(payload, JsonObject): 18 | self.payload = payload.to_dict() 19 | elif isinstance(payload, dict): 20 | self.payload = payload 21 | elif isinstance(payload, str): 22 | self.payload = {"text": payload} 23 | else: 24 | raise ValueError(f"Unsupported payload data type ({type(payload)})") 25 | 26 | def to_dict(self) -> dict: # skipcq: PYL-W0221 27 | d = {"envelope_id": self.envelope_id} 28 | if self.payload is not None: 29 | d["payload"] = self.payload 30 | return d 31 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/aiohttp_version_checker.py: -------------------------------------------------------------------------------- 1 | """Internal module for checking aiohttp compatibility of async modules""" 2 | import logging 3 | from typing import Callable 4 | 5 | 6 | def _print_warning_log(message: str) -> None: 7 | logging.getLogger(__name__).warning(message) 8 | 9 | 10 | def validate_aiohttp_version( 11 | aiohttp_version: str, 12 | print_warning: Callable[[str], None] = _print_warning_log, 13 | ): 14 | if aiohttp_version is not None: 15 | elements = aiohttp_version.split(".") 16 | if len(elements) >= 3: 17 | # patch version can be a non-numeric value 18 | major, minor, patch = int(elements[0]), int(elements[1]), elements[2] 19 | if major <= 2 or ( 20 | major == 3 and (minor == 6 or (minor == 7 and patch == "0")) 21 | ): 22 | print_warning( 23 | "We highly recommend upgrading aiohttp to 3.7.3 or higher versions." 24 | "An older version of the library may not work with the Slack server-side in the future." 25 | ) 26 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/interval_runner.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from threading import Thread, Event 4 | from typing import Callable 5 | 6 | 7 | class IntervalRunner: 8 | event: Event 9 | thread: Thread 10 | 11 | def __init__(self, target: Callable[[], None], interval_seconds: float = 0.1): 12 | self.event = threading.Event() 13 | self.target = target 14 | self.interval_seconds = interval_seconds 15 | self.thread = threading.Thread(target=self._run) 16 | self.thread.daemon = True 17 | 18 | def _run(self) -> None: 19 | while not self.event.is_set(): 20 | self.target() 21 | time.sleep(self.interval_seconds) 22 | 23 | def start(self) -> "IntervalRunner": 24 | self.thread.start() 25 | return self 26 | 27 | def is_alive(self) -> bool: 28 | return self.thread is not None and self.thread.is_alive() 29 | 30 | def shutdown(self): 31 | if self.thread.is_alive(): 32 | self.event.set() 33 | self.thread.join() 34 | self.thread = None 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Islam Ghanim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test-cost-anomaly-event.json: -------------------------------------------------------------------------------- 1 | { 2 | "accountId": "123456789033", 3 | "anomalyStartDate": "2020-12-13", 4 | "anomalyEndDate": "2020-12-15", 5 | "anomalyId": "newAnomaly", 6 | "dimensionalValue": "AlertingIntegTestDimension", 7 | "monitorArn": "arn:aws:ce::123456789012:anomalymonitor/522133ed-4937-4ea0-84cc-918a97d736ef", 8 | "anomalyScore": { 9 | "maxScore": 2.0, 10 | "currentScore": 1.0 11 | }, 12 | "impact": { 13 | "maxImpact": 151.0, 14 | "totalImpact": 1001.0 15 | }, 16 | "rootCauses": [ 17 | { 18 | "service": "AWS CloudTrail", 19 | "region": "ap-southeast-2", 20 | "linkedAccount": "123410611747", 21 | "usageType": "APS2-PaidEventsRecorded" 22 | }, 23 | { 24 | "service": "Amazon Simple Storage Service", 25 | "region": "us-east-1", 26 | "linkedAccount": "432110611747", 27 | "usageType": "Requests-Tier1" 28 | } 29 | ], 30 | "anomalyDetailsLink": "https://console.aws.amazon.com/cost-management/home#/anomaly-detection/monitors/522133ed-4937-4ea0-84cc-918a97d736ef/anomalies/newAnomaly" 31 | } -------------------------------------------------------------------------------- /lambda-code/slack_sdk/web/deprecation.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | 4 | # https://api.slack.com/changelog/2020-01-deprecating-antecedents-to-the-conversations-api 5 | deprecated_method_prefixes_2020_01 = [ 6 | "channels.", 7 | "groups.", 8 | "im.", 9 | "mpim.", 10 | "admin.conversations.whitelist.", 11 | ] 12 | 13 | 14 | def show_2020_01_deprecation(method_name: str): 15 | """Prints a warning if the given method is deprecated""" 16 | 17 | skip_deprecation = os.environ.get( 18 | "SLACKCLIENT_SKIP_DEPRECATION" 19 | ) # for unit tests etc. 20 | if skip_deprecation: 21 | return 22 | if not method_name: 23 | return 24 | 25 | matched_prefixes = [ 26 | prefix 27 | for prefix in deprecated_method_prefixes_2020_01 28 | if method_name.startswith(prefix) 29 | ] 30 | if len(matched_prefixes) > 0: 31 | message = ( 32 | f"{method_name} is deprecated. Please use the Conversations API instead. " 33 | "For more info, go to " 34 | "https://api.slack.com/changelog/2020-01-deprecating-antecedents-to-the-conversations-api" 35 | ) 36 | warnings.warn(message) 37 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/authorize_url_generator/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Sequence 2 | 3 | 4 | class AuthorizeUrlGenerator: 5 | def __init__( 6 | self, 7 | *, 8 | client_id: str, 9 | redirect_uri: Optional[str] = None, 10 | scopes: Optional[Sequence[str]] = None, 11 | user_scopes: Optional[Sequence[str]] = None, 12 | authorization_url: str = "https://slack.com/oauth/v2/authorize", 13 | ): 14 | self.client_id = client_id 15 | self.redirect_uri = redirect_uri 16 | self.scopes = scopes 17 | self.user_scopes = user_scopes 18 | self.authorization_url = authorization_url 19 | 20 | def generate(self, state: str): 21 | scopes = ",".join(self.scopes) if self.scopes else "" 22 | user_scopes = ",".join(self.user_scopes) if self.user_scopes else "" 23 | url = ( 24 | f"{self.authorization_url}?" 25 | f"state={state}&" 26 | f"client_id={self.client_id}&" 27 | f"scope={scopes}&" 28 | f"user_scope={user_scopes}" 29 | ) 30 | if self.redirect_uri is not None: 31 | url += f"&redirect_uri={self.redirect_uri}" 32 | return url 33 | -------------------------------------------------------------------------------- /appspec.yml: -------------------------------------------------------------------------------- 1 | # This is an appspec.yml template file for use with an AWS Lambda deployment in CodeDeploy. 2 | # The lines in this template starting with the hashtag symbol are 3 | # instructional comments and can be safely left in the file or 4 | # ignored. 5 | # For help completing this file, see the "AppSpec File Reference" in the 6 | # "CodeDeploy User Guide" at 7 | # https://docs.aws.amazon.com/codedeploy/latest/userguide/app-spec-ref.html 8 | version: 0.0 9 | # In the Resources section specify the name, alias, 10 | # target version, and (optional) the current version of your AWS Lambda function. 11 | Resources: 12 | - MyFunction: # Replace "MyFunction" with the name of your Lambda function 13 | Type: AWS::Lambda::Function 14 | Properties: 15 | Name: "cad-CostAnomalyToSlackLambda-2CREMuSJX4D0" # Specify the name of your Lambda function 16 | Alias: "prod" # Specify the alias for your Lambda function 17 | CurrentVersion: "3" # Specify the current version of your Lambda function 18 | TargetVersion: "2" # Specify the version of your Lambda function to deploy 19 | # (Optional) In the Hooks section, specify a validation Lambda function to run during 20 | # a lifecycle event. Replace "LifeCycleEvent" with BeforeAllowTraffic 21 | # or AfterAllowTraffic. -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/builtin/frame_header.py: -------------------------------------------------------------------------------- 1 | class FrameHeader: 2 | fin: int 3 | rsv1: int 4 | rsv2: int 5 | rsv3: int 6 | opcode: int 7 | masked: int 8 | length: int 9 | 10 | # Opcode 11 | # https://tools.ietf.org/html/rfc6455#section-5.2 12 | # Non-control frames 13 | # %x0 denotes a continuation frame 14 | OPCODE_CONTINUATION = 0x0 15 | # %x1 denotes a text frame 16 | OPCODE_TEXT = 0x1 17 | # %x2 denotes a binary frame 18 | OPCODE_BINARY = 0x2 19 | # %x3-7 are reserved for further non-control frames 20 | 21 | # Control frames 22 | # %x8 denotes a connection close 23 | OPCODE_CLOSE = 0x8 24 | # %x9 denotes a ping 25 | OPCODE_PING = 0x9 26 | # %xA denotes a pong 27 | OPCODE_PONG = 0xA 28 | 29 | # %xB-F are reserved for further control frames 30 | 31 | def __init__( 32 | self, 33 | opcode: int, 34 | fin: int = 1, 35 | rsv1: int = 0, 36 | rsv2: int = 0, 37 | rsv3: int = 0, 38 | masked: int = 0, 39 | length: int = 0, 40 | ): 41 | self.opcode = opcode 42 | self.fin = fin 43 | self.rsv1 = rsv1 44 | self.rsv2 = rsv2 45 | self.rsv3 = rsv3 46 | self.masked = masked 47 | self.length = length 48 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | * The SDK website: https://slack.dev/python-slack-sdk/ 3 | * PyPI package: https://pypi.org/project/slack-sdk/ 4 | 5 | Here is the list of key modules in this SDK: 6 | 7 | #### Web API Client 8 | 9 | * Web API client: `slack_sdk.web.client` 10 | * asyncio-based Web API client: `slack_sdk.web.async_client` 11 | 12 | #### Webhook / response_url Client 13 | 14 | * Webhook client: `slack_sdk.webhook.client` 15 | * asyncio-based Webhook client: `slack_sdk.webhook.async_client` 16 | 17 | #### Socket Mode Client 18 | 19 | * The built-in Socket Mode client: `slack_sdk.socket_mode.builtin.client` 20 | * [aiohttp](https://pypi.org/project/aiohttp/) based client: `slack_sdk.socket_mode.aiohttp` 21 | * [websocket_client](https://pypi.org/project/websocket-client/) based client: `slack_sdk.socket_mode.websocket_client` 22 | * [websockets](https://pypi.org/project/websockets/) based client: `slack_sdk.socket_mode.websockets` 23 | 24 | #### OAuth 25 | 26 | * `slack_sdk.oauth.installation_store.installation_store` 27 | * `slack_sdk.oauth.state_store` 28 | 29 | #### Audit Logs API Client 30 | 31 | * `slack_sdk.audit_logs.v1.client` 32 | * `slack_sdk.audit_logs.v1.async_client` 33 | 34 | #### SCIM API Client 35 | 36 | * `slack_sdk.scim.v1.client` 37 | * `slack_sdk.scim.v1.async_client` 38 | 39 | """ 40 | import logging 41 | from logging import NullHandler 42 | 43 | # from .rtm import RTMClient # noqa 44 | from .web import WebClient # noqa 45 | from .webhook import WebhookClient # noqa 46 | 47 | # Set default logging handler to avoid "No handler found" warnings. 48 | logging.getLogger(__name__).addHandler(NullHandler()) 49 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/webhook/internal_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Dict, Any 3 | 4 | from slack_sdk.web.internal_utils import ( 5 | _parse_web_class_objects, 6 | get_user_agent, 7 | convert_bool_to_0_or_1, 8 | ) 9 | from .webhook_response import WebhookResponse 10 | 11 | 12 | def _build_body(original_body: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: 13 | if original_body: 14 | body = {k: v for k, v in original_body.items() if v is not None} 15 | body = convert_bool_to_0_or_1(body) 16 | _parse_web_class_objects(body) 17 | return body 18 | return None 19 | 20 | 21 | def _build_request_headers( 22 | default_headers: Dict[str, str], 23 | additional_headers: Optional[Dict[str, str]], 24 | ) -> Dict[str, str]: 25 | if default_headers is None and additional_headers is None: 26 | return {} 27 | 28 | request_headers = { 29 | "Content-Type": "application/json;charset=utf-8", 30 | } 31 | if default_headers is None or "User-Agent" not in default_headers: 32 | request_headers["User-Agent"] = get_user_agent() 33 | 34 | request_headers.update(default_headers) 35 | if additional_headers: 36 | request_headers.update(additional_headers) 37 | return request_headers 38 | 39 | 40 | def _debug_log_response(logger, resp: WebhookResponse) -> None: 41 | if logger.level <= logging.DEBUG: 42 | logger.debug( 43 | "Received the following response - " 44 | f"status: {resp.status_code}, " 45 | f"headers: {(dict(resp.headers))}, " 46 | f"body: {resp.body}" 47 | ) 48 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/audit_logs/v1/internal_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Dict, Any 3 | from urllib.parse import quote 4 | 5 | from slack_sdk.web.internal_utils import get_user_agent 6 | from .response import AuditLogsResponse 7 | 8 | 9 | def _build_query(params: Optional[Dict[str, Any]]) -> str: 10 | if params is not None and len(params) > 0: 11 | return "&".join( 12 | { 13 | f"{quote(str(k))}={quote(str(v))}" 14 | for k, v in params.items() 15 | if v is not None 16 | } 17 | ) 18 | return "" 19 | 20 | 21 | def _build_request_headers( 22 | token: str, 23 | default_headers: Dict[str, str], 24 | additional_headers: Optional[Dict[str, str]], 25 | ) -> Dict[str, str]: 26 | request_headers = { 27 | "Content-Type": "application/json;charset=utf-8", 28 | "Authorization": f"Bearer {token}", 29 | } 30 | if default_headers is None or "User-Agent" not in default_headers: 31 | request_headers["User-Agent"] = get_user_agent() 32 | if default_headers is not None: 33 | request_headers.update(default_headers) 34 | if additional_headers is not None: 35 | request_headers.update(additional_headers) 36 | return request_headers 37 | 38 | 39 | def _debug_log_response(logger, resp: AuditLogsResponse) -> None: 40 | if logger.level <= logging.DEBUG: 41 | logger.debug( 42 | "Received the following response - " 43 | f"status: {resp.status_code}, " 44 | f"headers: {(dict(resp.headers))}, " 45 | f"body: {resp.raw_body}" 46 | ) 47 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/errors/__init__.py: -------------------------------------------------------------------------------- 1 | """Errors that can be raised by this SDK""" 2 | 3 | 4 | class SlackClientError(Exception): 5 | """Base class for Client errors""" 6 | 7 | 8 | class BotUserAccessError(SlackClientError): 9 | """Error raised when an 'xoxb-*' token is 10 | being used for a Slack API method that only accepts 'xoxp-*' tokens. 11 | """ 12 | 13 | 14 | class SlackRequestError(SlackClientError): 15 | """Error raised when there's a problem with the request that's being submitted.""" 16 | 17 | 18 | class SlackApiError(SlackClientError): 19 | """Error raised when Slack does not send the expected response. 20 | 21 | Attributes: 22 | response (SlackResponse): The SlackResponse object containing all of the data sent back from the API. 23 | 24 | Note: 25 | The message (str) passed into the exception is used when 26 | a user converts the exception to a str. 27 | i.e. str(SlackApiError("This text will be sent as a string.")) 28 | """ 29 | 30 | def __init__(self, message, response): 31 | msg = f"{message}\nThe server responded with: {response}" 32 | self.response = response 33 | super(SlackApiError, self).__init__(msg) 34 | 35 | 36 | class SlackClientNotConnectedError(SlackClientError): 37 | """Error raised when attempting to send messages over the websocket when the 38 | connection is closed.""" 39 | 40 | 41 | class SlackObjectFormationError(SlackClientError): 42 | """Error raised when a constructed object is not valid/malformed""" 43 | 44 | 45 | class SlackClientConfigurationError(SlackClientError): 46 | """Error raised when attempting to send messages over the websocket when the 47 | connection is closed.""" 48 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/state_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Dict, Sequence, Union 2 | 3 | 4 | class OAuthStateUtils: 5 | cookie_name: str 6 | expiration_seconds: int 7 | 8 | default_cookie_name: str = "slack-app-oauth-state" 9 | default_expiration_seconds: int = 60 * 10 # 10 minutes 10 | 11 | def __init__( 12 | self, 13 | *, 14 | cookie_name: str = default_cookie_name, 15 | expiration_seconds: int = default_expiration_seconds, 16 | ): 17 | self.cookie_name = cookie_name 18 | self.expiration_seconds = expiration_seconds 19 | 20 | def build_set_cookie_for_new_state(self, state: str) -> str: 21 | return ( 22 | f"{self.cookie_name}={state}; " 23 | "Secure; " 24 | "HttpOnly; " 25 | "Path=/; " 26 | f"Max-Age={self.expiration_seconds}" 27 | ) 28 | 29 | def build_set_cookie_for_deletion(self) -> str: 30 | return ( 31 | f"{self.cookie_name}=deleted; " 32 | "Secure; " 33 | "HttpOnly; " 34 | "Path=/; " 35 | "Expires=Thu, 01 Jan 1970 00:00:00 GMT" 36 | ) 37 | 38 | def is_valid_browser( 39 | self, 40 | state: Optional[str], 41 | request_headers: Dict[str, Union[str, Sequence[str]]], 42 | ) -> bool: 43 | if ( 44 | state is None 45 | or request_headers is None 46 | or request_headers.get("cookie", None) is None 47 | ): 48 | return False 49 | cookies = request_headers["cookie"] 50 | if isinstance(cookies, str): 51 | cookies = [cookies] 52 | for cookie in cookies: 53 | values = cookie.split(";") 54 | for value in values: 55 | if value.strip() == f"{self.cookie_name}={state}": 56 | return True 57 | return False 58 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Classes for constructing Slack-specific data structure""" 2 | 3 | import logging 4 | from typing import Union, Dict, Any, Sequence, List 5 | 6 | from .basic_objects import BaseObject # noqa 7 | from .basic_objects import EnumValidator # noqa 8 | from .basic_objects import JsonObject # noqa 9 | from .basic_objects import JsonValidator # noqa 10 | 11 | 12 | # NOTE: used only for legacy components - don't use this for Block Kit 13 | def extract_json( 14 | item_or_items: Union[JsonObject, Sequence[JsonObject]], *format_args 15 | ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: 16 | """ 17 | Given a sequence (or single item), attempt to call the to_dict() method on each 18 | item and return a plain list. If item is not the expected type, return it 19 | unmodified, in case it's already a plain dict or some other user created class. 20 | 21 | Args: 22 | item_or_items: item(s) to go through 23 | format_args: Any formatting specifiers to pass into the object's to_dict 24 | method 25 | """ 26 | try: 27 | return [ 28 | elem.to_dict(*format_args) if isinstance(elem, JsonObject) else elem 29 | for elem in item_or_items 30 | ] 31 | except TypeError: # not iterable, so try returning it as a single item 32 | return ( 33 | item_or_items.to_dict(*format_args) 34 | if isinstance(item_or_items, JsonObject) 35 | else item_or_items 36 | ) 37 | 38 | 39 | def show_unknown_key_warning(name: Union[str, object], others: dict): 40 | if "type" in others: 41 | others.pop("type") 42 | if len(others) > 0: 43 | keys = ", ".join(others.keys()) 44 | logger = logging.getLogger(__name__) 45 | if isinstance(name, object): 46 | name = name.__class__.__name__ 47 | logger.debug( 48 | f"!!! {name}'s constructor args ({keys}) were ignored." 49 | f"If they should be supported by this library, report this issue to the project :bow: " 50 | f"https://github.com/slackapi/python-slack-sdk/issues" 51 | ) 52 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/request.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional 2 | 3 | from slack_sdk.models import JsonObject 4 | 5 | 6 | class SocketModeRequest: 7 | type: str 8 | envelope_id: str 9 | payload: dict 10 | accepts_response_payload: bool 11 | retry_attempt: Optional[int] # events_api 12 | retry_reason: Optional[str] # events_api 13 | 14 | def __init__( 15 | self, 16 | type: str, 17 | envelope_id: str, 18 | payload: Union[dict, JsonObject, str], 19 | accepts_response_payload: Optional[bool] = None, 20 | retry_attempt: Optional[int] = None, 21 | retry_reason: Optional[str] = None, 22 | ): 23 | self.type = type 24 | self.envelope_id = envelope_id 25 | 26 | if isinstance(payload, JsonObject): 27 | self.payload = payload.to_dict() 28 | elif isinstance(payload, dict): 29 | self.payload = payload 30 | elif isinstance(payload, str): 31 | self.payload = {"text": payload} 32 | else: 33 | raise ValueError(f"Unsupported payload data type ({type(payload)})") 34 | 35 | self.accepts_response_payload = accepts_response_payload or False 36 | self.retry_attempt = retry_attempt 37 | self.retry_reason = retry_reason 38 | 39 | @classmethod 40 | def from_dict(cls, message: dict) -> Optional["SocketModeRequest"]: 41 | if all(k in message for k in ("type", "envelope_id", "payload")): 42 | return SocketModeRequest( 43 | type=message.get("type"), 44 | envelope_id=message.get("envelope_id"), 45 | payload=message.get("payload"), 46 | accepts_response_payload=message.get("accepts_response_payload") 47 | or False, 48 | retry_attempt=message.get("retry_attempt"), 49 | retry_reason=message.get("retry_reason"), 50 | ) 51 | return None 52 | 53 | def to_dict(self) -> dict: # skipcq: PYL-W0221 54 | d = {"envelope_id": self.envelope_id} 55 | if self.payload is not None: 56 | d["payload"] = self.payload 57 | return d 58 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/redirect_uri_page_renderer/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class RedirectUriPageRenderer: 5 | def __init__( 6 | self, 7 | *, 8 | install_path: str, 9 | redirect_uri_path: str, 10 | success_url: Optional[str] = None, 11 | failure_url: Optional[str] = None, 12 | ): 13 | self.install_path = install_path 14 | self.redirect_uri_path = redirect_uri_path 15 | self.success_url = success_url 16 | self.failure_url = failure_url 17 | 18 | def render_success_page( 19 | self, 20 | app_id: str, 21 | team_id: Optional[str], 22 | is_enterprise_install: Optional[bool] = None, 23 | enterprise_url: Optional[str] = None, 24 | ) -> str: 25 | url = self.success_url 26 | if url is None: 27 | if ( 28 | is_enterprise_install is True 29 | and enterprise_url is not None 30 | and app_id is not None 31 | ): 32 | url = f"{enterprise_url}manage/organization/apps/profile/{app_id}/workspaces/add" 33 | elif team_id is None or app_id is None: 34 | url = "slack://open" 35 | else: 36 | url = f"slack://app?team={team_id}&id={app_id}" 37 | browser_url = f"https://app.slack.com/client/{team_id}" 38 | 39 | return f""" 40 | 41 | 42 | 43 | 50 | 51 | 52 |

Thank you!

53 |

Redirecting to the Slack App... click here. If you use the browser version of Slack, click this link instead.

54 | 55 | 56 | """ # noqa: E501 57 | 58 | def render_failure_page(self, reason: str) -> str: 59 | return f""" 60 | 61 | 62 | 69 | 70 | 71 |

Oops, Something Went Wrong!

72 |

Please try again from here or contact the app owner (reason: {reason})

73 | 74 | 75 | """ 76 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/state_store/file/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import time 4 | from logging import Logger 5 | from pathlib import Path 6 | from typing import Union, Optional 7 | from uuid import uuid4 8 | 9 | from ..async_state_store import AsyncOAuthStateStore 10 | from ..state_store import OAuthStateStore 11 | 12 | 13 | class FileOAuthStateStore(OAuthStateStore, AsyncOAuthStateStore): 14 | def __init__( 15 | self, 16 | *, 17 | expiration_seconds: int, 18 | base_dir: str = str(Path.home()) + "/.bolt-app-oauth-state", 19 | client_id: Optional[str] = None, 20 | logger: Logger = logging.getLogger(__name__), 21 | ): 22 | self.expiration_seconds = expiration_seconds 23 | 24 | self.base_dir = base_dir 25 | self.client_id = client_id 26 | if self.client_id is not None: 27 | self.base_dir = f"{self.base_dir}/{self.client_id}" 28 | self._logger = logger 29 | 30 | @property 31 | def logger(self) -> Logger: 32 | if self._logger is None: 33 | self._logger = logging.getLogger(__name__) 34 | return self._logger 35 | 36 | async def async_issue(self, *args, **kwargs) -> str: 37 | return self.issue(*args, **kwargs) 38 | 39 | async def async_consume(self, state: str) -> bool: 40 | return self.consume(state) 41 | 42 | def issue(self, *args, **kwargs) -> str: 43 | state = str(uuid4()) 44 | self._mkdir(self.base_dir) 45 | filepath = f"{self.base_dir}/{state}" 46 | with open(filepath, "w") as f: 47 | content = str(time.time()) 48 | f.write(content) 49 | return state 50 | 51 | def consume(self, state: str) -> bool: 52 | filepath = f"{self.base_dir}/{state}" 53 | try: 54 | with open(filepath) as f: 55 | created = float(f.read()) 56 | expiration = created + self.expiration_seconds 57 | still_valid: bool = time.time() < expiration 58 | 59 | os.remove(filepath) # consume the file by deleting it 60 | return still_valid 61 | 62 | except FileNotFoundError as e: 63 | message = f"Failed to find any persistent data for state: {state} - {e}" 64 | self.logger.warning(message) 65 | return False 66 | 67 | @staticmethod 68 | def _mkdir(path: Union[str, Path]): 69 | if isinstance(path, str): 70 | path = Path(path) 71 | path.mkdir(parents=True, exist_ok=True) 72 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/models/blocks/__init__.py: -------------------------------------------------------------------------------- 1 | """Block Kit data model objects 2 | 3 | To learn more about Block Kit, please check the following resources and tools: 4 | 5 | * https://api.slack.com/block-kit 6 | * https://api.slack.com/reference/block-kit/blocks 7 | * https://app.slack.com/block-kit-builder 8 | """ 9 | from .basic_components import ButtonStyles # noqa 10 | from .basic_components import ConfirmObject # noqa 11 | from .basic_components import DynamicSelectElementTypes # noqa 12 | from .basic_components import MarkdownTextObject # noqa 13 | from .basic_components import Option # noqa 14 | from .basic_components import OptionGroup # noqa 15 | from .basic_components import PlainTextObject # noqa 16 | from .basic_components import TextObject # noqa 17 | from .block_elements import BlockElement # noqa 18 | from .block_elements import ButtonElement # noqa 19 | from .block_elements import ChannelMultiSelectElement # noqa 20 | from .block_elements import ChannelSelectElement # noqa 21 | from .block_elements import CheckboxesElement # noqa 22 | from .block_elements import ConversationFilter # noqa 23 | from .block_elements import ConversationMultiSelectElement # noqa 24 | from .block_elements import ConversationSelectElement # noqa 25 | from .block_elements import DatePickerElement # noqa 26 | from .block_elements import ExternalDataMultiSelectElement # noqa 27 | from .block_elements import ExternalDataSelectElement # noqa 28 | from .block_elements import ImageElement # noqa 29 | from .block_elements import InputInteractiveElement # noqa 30 | from .block_elements import InteractiveElement # noqa 31 | from .block_elements import LinkButtonElement # noqa 32 | from .block_elements import OverflowMenuElement # noqa 33 | from .block_elements import PlainTextInputElement # noqa 34 | from .block_elements import RadioButtonsElement # noqa 35 | from .block_elements import SelectElement # noqa 36 | from .block_elements import StaticMultiSelectElement # noqa 37 | from .block_elements import StaticSelectElement # noqa 38 | from .block_elements import UserMultiSelectElement # noqa 39 | from .block_elements import UserSelectElement # noqa 40 | from .blocks import ActionsBlock # noqa 41 | from .blocks import Block # noqa 42 | from .blocks import CallBlock # noqa 43 | from .blocks import ContextBlock # noqa 44 | from .blocks import DividerBlock # noqa 45 | from .blocks import FileBlock # noqa 46 | from .blocks import HeaderBlock # noqa 47 | from .blocks import ImageBlock # noqa 48 | from .blocks import InputBlock # noqa 49 | from .blocks import SectionBlock # noqa 50 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/state_store/amazon_s3/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from logging import Logger 4 | from uuid import uuid4 5 | 6 | from botocore.client import BaseClient 7 | 8 | from ..async_state_store import AsyncOAuthStateStore 9 | from ..state_store import OAuthStateStore 10 | 11 | 12 | class AmazonS3OAuthStateStore(OAuthStateStore, AsyncOAuthStateStore): 13 | def __init__( 14 | self, 15 | *, 16 | s3_client: BaseClient, 17 | bucket_name: str, 18 | expiration_seconds: int, 19 | logger: Logger = logging.getLogger(__name__), 20 | ): 21 | self.s3_client = s3_client 22 | self.bucket_name = bucket_name 23 | self.expiration_seconds = expiration_seconds 24 | self._logger = logger 25 | 26 | @property 27 | def logger(self) -> Logger: 28 | if self._logger is None: 29 | self._logger = logging.getLogger(__name__) 30 | return self._logger 31 | 32 | async def async_issue(self, *args, **kwargs) -> str: 33 | return self.issue(*args, **kwargs) 34 | 35 | async def async_consume(self, state: str) -> bool: 36 | return self.consume(state) 37 | 38 | def issue(self, *args, **kwargs) -> str: 39 | state = str(uuid4()) 40 | response = self.s3_client.put_object( 41 | Bucket=self.bucket_name, 42 | Body=str(time.time()), 43 | Key=state, 44 | ) 45 | self.logger.debug(f"S3 put_object response: {response}") 46 | return state 47 | 48 | def consume(self, state: str) -> bool: 49 | try: 50 | fetch_response = self.s3_client.get_object( 51 | Bucket=self.bucket_name, 52 | Key=state, 53 | ) 54 | self.logger.debug(f"S3 get_object response: {fetch_response}") 55 | body = fetch_response["Body"].read().decode("utf-8") 56 | created = float(body) 57 | expiration = created + self.expiration_seconds 58 | still_valid: bool = time.time() < expiration 59 | 60 | deletion_response = self.s3_client.delete_object( 61 | Bucket=self.bucket_name, 62 | Key=state, 63 | ) 64 | self.logger.debug(f"S3 delete_object response: {deletion_response}") 65 | return still_valid 66 | except Exception as e: # skipcq: PYL-W0703 67 | message = f"Failed to find any persistent data for state: {state} - {e}" 68 | self.logger.warning(message) 69 | return False 70 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/signature/__init__.py: -------------------------------------------------------------------------------- 1 | """Slack request signature verifier""" 2 | import hashlib 3 | import hmac 4 | from time import time 5 | from typing import Dict, Optional, Union 6 | 7 | 8 | class Clock: 9 | def now(self) -> float: # skipcq: PYL-R0201 10 | return time() 11 | 12 | 13 | class SignatureVerifier: 14 | def __init__(self, signing_secret: str, clock: Clock = Clock()): 15 | """Slack request signature verifier 16 | 17 | Slack signs its requests using a secret that's unique to your app. 18 | With the help of signing secrets, your app can more confidently verify 19 | whether requests from us are authentic. 20 | https://api.slack.com/authentication/verifying-requests-from-slack 21 | """ 22 | self.signing_secret = signing_secret 23 | self.clock = clock 24 | 25 | def is_valid_request( 26 | self, 27 | body: Union[str, bytes], 28 | headers: Dict[str, str], 29 | ) -> bool: 30 | """Verifies if the given signature is valid""" 31 | if headers is None: 32 | return False 33 | normalized_headers = {k.lower(): v for k, v in headers.items()} 34 | return self.is_valid( 35 | body=body, 36 | timestamp=normalized_headers.get("x-slack-request-timestamp", None), 37 | signature=normalized_headers.get("x-slack-signature", None), 38 | ) 39 | 40 | def is_valid( 41 | self, 42 | body: Union[str, bytes], 43 | timestamp: str, 44 | signature: str, 45 | ) -> bool: 46 | """Verifies if the given signature is valid""" 47 | if timestamp is None or signature is None: 48 | return False 49 | 50 | if abs(self.clock.now() - int(timestamp)) > 60 * 5: 51 | return False 52 | 53 | calculated_signature = self.generate_signature(timestamp=timestamp, body=body) 54 | if calculated_signature is None: 55 | return False 56 | return hmac.compare_digest(calculated_signature, signature) 57 | 58 | def generate_signature( 59 | self, *, timestamp: str, body: Union[str, bytes] 60 | ) -> Optional[str]: 61 | """Generates a signature""" 62 | if timestamp is None: 63 | return None 64 | if body is None: 65 | body = "" 66 | if isinstance(body, bytes): 67 | body = body.decode("utf-8") 68 | 69 | format_req = str.encode(f"v0:{timestamp}:{body}") 70 | encoded_secret = str.encode(self.signing_secret) 71 | request_hash = hmac.new(encoded_secret, format_req, hashlib.sha256).hexdigest() 72 | calculated_signature = f"v0={request_hash}" 73 | return calculated_signature 74 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/scim/v1/group.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Union, Dict, Any 2 | 3 | from .default_arg import DefaultArg, NotGiven 4 | from .internal_utils import _to_dict_without_not_given, _is_iterable 5 | 6 | 7 | class GroupMember: 8 | display: Union[Optional[str], DefaultArg] 9 | value: Union[Optional[str], DefaultArg] 10 | unknown_fields: Dict[str, Any] 11 | 12 | def __init__( 13 | self, 14 | *, 15 | display: Union[Optional[str], DefaultArg] = NotGiven, 16 | value: Union[Optional[str], DefaultArg] = NotGiven, 17 | **kwargs, 18 | ) -> None: 19 | self.display = display 20 | self.value = value 21 | self.unknown_fields = kwargs 22 | 23 | def to_dict(self): 24 | return _to_dict_without_not_given(self) 25 | 26 | 27 | class GroupMeta: 28 | created: Union[Optional[str], DefaultArg] 29 | location: Union[Optional[str], DefaultArg] 30 | unknown_fields: Dict[str, Any] 31 | 32 | def __init__( 33 | self, 34 | *, 35 | created: Union[Optional[str], DefaultArg] = NotGiven, 36 | location: Union[Optional[str], DefaultArg] = NotGiven, 37 | **kwargs, 38 | ) -> None: 39 | self.created = created 40 | self.location = location 41 | self.unknown_fields = kwargs 42 | 43 | def to_dict(self): 44 | return _to_dict_without_not_given(self) 45 | 46 | 47 | class Group: 48 | display_name: Union[Optional[str], DefaultArg] 49 | id: Union[Optional[str], DefaultArg] 50 | members: Union[Optional[List[GroupMember]], DefaultArg] 51 | meta: Union[Optional[GroupMeta], DefaultArg] 52 | schemas: Union[Optional[List[str]], DefaultArg] 53 | unknown_fields: Dict[str, Any] 54 | 55 | def __init__( 56 | self, 57 | *, 58 | display_name: Union[Optional[str], DefaultArg] = NotGiven, 59 | id: Union[Optional[str], DefaultArg] = NotGiven, 60 | members: Union[Optional[List[GroupMember]], DefaultArg] = NotGiven, 61 | meta: Union[Optional[GroupMeta], DefaultArg] = NotGiven, 62 | schemas: Union[Optional[List[str]], DefaultArg] = NotGiven, 63 | **kwargs, 64 | ) -> None: 65 | self.display_name = display_name 66 | self.id = id 67 | self.members = ( 68 | [a if isinstance(a, GroupMember) else GroupMember(**a) for a in members] 69 | if _is_iterable(members) 70 | else members 71 | ) 72 | self.meta = ( 73 | GroupMeta(**meta) if meta is not None and isinstance(meta, dict) else meta 74 | ) 75 | self.schemas = schemas 76 | self.unknown_fields = kwargs 77 | 78 | def to_dict(self): 79 | return _to_dict_without_not_given(self) 80 | 81 | def __repr__(self): 82 | return f"" 83 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/state_store/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from datetime import datetime 4 | from logging import Logger 5 | from uuid import uuid4 6 | 7 | from ..state_store import OAuthStateStore 8 | import sqlalchemy 9 | from sqlalchemy import Table, Column, Integer, String, DateTime, and_, MetaData 10 | from sqlalchemy.engine import Engine 11 | 12 | 13 | class SQLAlchemyOAuthStateStore(OAuthStateStore): 14 | default_table_name: str = "slack_oauth_states" 15 | 16 | expiration_seconds: int 17 | engine: Engine 18 | metadata: MetaData 19 | oauth_states: Table 20 | 21 | @classmethod 22 | def build_oauth_states_table(cls, metadata: MetaData, table_name: str) -> Table: 23 | return sqlalchemy.Table( 24 | table_name, 25 | metadata, 26 | metadata, 27 | Column("id", Integer, primary_key=True, autoincrement=True), 28 | Column("state", String, nullable=False), 29 | Column("expire_at", DateTime, nullable=False), 30 | ) 31 | 32 | def __init__( 33 | self, 34 | expiration_seconds: int, 35 | engine: Engine, 36 | logger: Logger = logging.getLogger(__name__), 37 | table_name: str = default_table_name, 38 | ): 39 | self.expiration_seconds = expiration_seconds 40 | self._logger = logger 41 | self.engine = engine 42 | self.metadata = MetaData() 43 | self.oauth_states = self.build_oauth_states_table(self.metadata, table_name) 44 | 45 | @property 46 | def logger(self) -> Logger: 47 | if self._logger is None: 48 | self._logger = logging.getLogger(__name__) 49 | return self._logger 50 | 51 | def issue(self, *args, **kwargs) -> str: 52 | state: str = str(uuid4()) 53 | now = datetime.utcfromtimestamp(time.time() + self.expiration_seconds) 54 | with self.engine.begin() as conn: 55 | conn.execute( 56 | self.oauth_states.insert(), 57 | {"state": state, "expire_at": now}, 58 | ) 59 | return state 60 | 61 | def consume(self, state: str) -> bool: 62 | try: 63 | with self.engine.begin() as conn: 64 | c = self.oauth_states.c 65 | query = self.oauth_states.select().where( 66 | and_(c.state == state, c.expire_at > datetime.utcnow()) 67 | ) 68 | result = conn.execute(query) 69 | for row in result: 70 | self.logger.debug(f"consume's query result: {row}") 71 | conn.execute(self.oauth_states.delete().where(c.id == row["id"])) 72 | return True 73 | return False 74 | except Exception as e: # skipcq: PYL-W0703 75 | message = f"Failed to find any persistent data for state: {state} - {e}" 76 | self.logger.warning(message) 77 | return False 78 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/models/bot.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, Union, Dict, Any, Sequence 3 | 4 | 5 | class Bot: 6 | app_id: Optional[str] 7 | enterprise_id: Optional[str] 8 | enterprise_name: Optional[str] 9 | team_id: Optional[str] 10 | team_name: Optional[str] 11 | bot_token: str 12 | bot_id: str 13 | bot_user_id: str 14 | bot_scopes: Sequence[str] 15 | is_enterprise_install: bool 16 | installed_at: float 17 | 18 | custom_values: Dict[str, Any] 19 | 20 | def __init__( 21 | self, 22 | *, 23 | app_id: Optional[str] = None, 24 | # org / workspace 25 | enterprise_id: Optional[str] = None, 26 | enterprise_name: Optional[str] = None, 27 | team_id: Optional[str] = None, 28 | team_name: Optional[str] = None, 29 | # bot 30 | bot_token: str, 31 | bot_id: str, 32 | bot_user_id: str, 33 | bot_scopes: Union[str, Sequence[str]] = "", 34 | is_enterprise_install: Optional[bool] = False, 35 | # timestamps 36 | installed_at: float, 37 | # custom values 38 | custom_values: Optional[Dict[str, Any]] = None 39 | ): 40 | self.app_id = app_id 41 | self.enterprise_id = enterprise_id 42 | self.enterprise_name = enterprise_name 43 | self.team_id = team_id 44 | self.team_name = team_name 45 | 46 | self.bot_token = bot_token 47 | self.bot_id = bot_id 48 | self.bot_user_id = bot_user_id 49 | if isinstance(bot_scopes, str): 50 | self.bot_scopes = bot_scopes.split(",") if len(bot_scopes) > 0 else [] 51 | else: 52 | self.bot_scopes = bot_scopes 53 | self.is_enterprise_install = is_enterprise_install or False 54 | self.installed_at = installed_at 55 | self.custom_values = custom_values if custom_values is not None else {} 56 | 57 | def set_custom_value(self, name: str, value: Any): 58 | self.custom_values[name] = value 59 | 60 | def get_custom_value(self, name: str) -> Optional[Any]: 61 | return self.custom_values.get(name) 62 | 63 | def to_dict(self) -> Dict[str, Any]: 64 | standard_values = { 65 | "app_id": self.app_id, 66 | "enterprise_id": self.enterprise_id, 67 | "enterprise_name": self.enterprise_name, 68 | "team_id": self.team_id, 69 | "team_name": self.team_name, 70 | "bot_token": self.bot_token, 71 | "bot_id": self.bot_id, 72 | "bot_user_id": self.bot_user_id, 73 | "bot_scopes": ",".join(self.bot_scopes) if self.bot_scopes else None, 74 | "is_enterprise_install": self.is_enterprise_install, 75 | "installed_at": datetime.utcfromtimestamp(self.installed_at), 76 | } 77 | # prioritize standard_values over custom_values 78 | # when the same keys exist in both 79 | return {**self.custom_values, **standard_values} 80 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/models/messages/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, Union 3 | 4 | from slack_sdk.models.basic_objects import BaseObject 5 | 6 | 7 | class Link(BaseObject): 8 | def __init__(self, *, url: str, text: str): 9 | """Base class used to generate links in Slack's not-quite Markdown, not quite HTML syntax 10 | https://api.slack.com/reference/surfaces/formatting#linking_to_urls 11 | """ 12 | self.url = url 13 | self.text = text 14 | 15 | def __str__(self): 16 | if self.text: 17 | separator = "|" 18 | else: 19 | separator = "" 20 | return f"<{self.url}{separator}{self.text}>" 21 | 22 | 23 | class DateLink(Link): 24 | def __init__( 25 | self, 26 | *, 27 | date: Union[datetime, int], 28 | date_format: str, 29 | fallback: str, 30 | link: Optional[str] = None, 31 | ): 32 | """Text containing a date or time should display that date in the local timezone of the person seeing the text. 33 | https://api.slack.com/reference/surfaces/formatting#date-formatting 34 | """ 35 | if isinstance(date, datetime): 36 | epoch = int(date.timestamp()) 37 | else: 38 | epoch = date 39 | if link is not None: 40 | link = f"^{link}" 41 | else: 42 | link = "" 43 | super().__init__(url=f"!date^{epoch}^{date_format}{link}", text=fallback) 44 | 45 | 46 | class ObjectLink(Link): 47 | prefix_mapping = { 48 | "C": "#", # channel 49 | "G": "#", # group message 50 | "U": "@", # user 51 | "W": "@", # workspace user (enterprise) 52 | "B": "@", # bot user 53 | "S": "!subteam^", # user groups, originally known as subteams 54 | } 55 | 56 | def __init__(self, *, object_id: str, text: str = ""): 57 | """Convenience class to create links to specific object types 58 | https://api.slack.com/reference/surfaces/formatting#linking-channels 59 | """ 60 | prefix = self.prefix_mapping.get(object_id[0].upper(), "@") 61 | super().__init__(url=f"{prefix}{object_id}", text=text) 62 | 63 | 64 | class ChannelLink(Link): 65 | def __init__(self): 66 | """Represents an @channel link, which notifies everyone present in this channel. 67 | https://api.slack.com/reference/surfaces/formatting 68 | """ 69 | super().__init__(url="!channel", text="channel") 70 | 71 | 72 | class HereLink(Link): 73 | def __init__(self): 74 | """Represents an @here link, which notifies all online users of this channel. 75 | https://api.slack.com/reference/surfaces/formatting 76 | """ 77 | super().__init__(url="!here", text="here") 78 | 79 | 80 | class EveryoneLink(Link): 81 | def __init__(self): 82 | """Represents an @everyone link, which notifies all users of this workspace. 83 | https://api.slack.com/reference/surfaces/formatting 84 | """ 85 | super().__init__(url="!everyone", text="everyone") 86 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/installation_store.py: -------------------------------------------------------------------------------- 1 | """Slack installation data store 2 | 3 | Refer to https://slack.dev/python-slack-sdk/oauth/ for details. 4 | """ 5 | from logging import Logger 6 | from typing import Optional 7 | 8 | from .models.bot import Bot 9 | from .models.installation import Installation 10 | 11 | 12 | class InstallationStore: 13 | """The installation store interface. 14 | 15 | The minimum required methods are: 16 | 17 | * save(installation) 18 | * find_installation(enterprise_id, team_id, user_id, is_enterprise_install) 19 | 20 | If you would like to properly handle app uninstallations and token revocations, 21 | the following methods should be implemented. 22 | 23 | * delete_installation(enterprise_id, team_id, user_id) 24 | * delete_all(enterprise_id, team_id) 25 | 26 | If your app needs only bot scope installations, the simpler way to implement would be: 27 | 28 | * save(installation) 29 | * find_bot(enterprise_id, team_id, is_enterprise_install) 30 | * delete_bot(enterprise_id, team_id) 31 | * delete_all(enterprise_id, team_id) 32 | """ 33 | 34 | @property 35 | def logger(self) -> Logger: 36 | raise NotImplementedError() 37 | 38 | def save(self, installation: Installation): 39 | """Saves a new installation data""" 40 | raise NotImplementedError() 41 | 42 | def find_bot( 43 | self, 44 | *, 45 | enterprise_id: Optional[str], 46 | team_id: Optional[str], 47 | is_enterprise_install: Optional[bool] = False, 48 | ) -> Optional[Bot]: 49 | """Finds a bot scope installation per workspace / org""" 50 | raise NotImplementedError() 51 | 52 | def find_installation( 53 | self, 54 | *, 55 | enterprise_id: Optional[str], 56 | team_id: Optional[str], 57 | user_id: Optional[str] = None, 58 | is_enterprise_install: Optional[bool] = False, 59 | ) -> Optional[Installation]: 60 | """Finds a relevant installation for the given IDs. 61 | If the user_id is absent, this method may return the latest installation in the workspace / org. 62 | """ 63 | raise NotImplementedError() 64 | 65 | def delete_bot( 66 | self, 67 | *, 68 | enterprise_id: Optional[str], 69 | team_id: Optional[str], 70 | ) -> None: 71 | """Deletes a bot scope installation per workspace / org""" 72 | raise NotImplementedError() 73 | 74 | def delete_installation( 75 | self, 76 | *, 77 | enterprise_id: Optional[str], 78 | team_id: Optional[str], 79 | user_id: Optional[str] = None, 80 | ) -> None: 81 | """Deletes an installation that matches the given IDs""" 82 | raise NotImplementedError() 83 | 84 | def delete_all( 85 | self, 86 | *, 87 | enterprise_id: Optional[str], 88 | team_id: Optional[str], 89 | ): 90 | """Deletes all installation data for the given workspace / org""" 91 | self.delete_bot(enterprise_id=enterprise_id, team_id=team_id) 92 | self.delete_installation(enterprise_id=enterprise_id, team_id=team_id) 93 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/async_installation_store.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | from typing import Optional 3 | 4 | from .models.bot import Bot 5 | from .models.installation import Installation 6 | 7 | 8 | class AsyncInstallationStore: 9 | """The installation store interface for asyncio-based apps. 10 | 11 | The minimum required methods are: 12 | 13 | * async_save(installation) 14 | * async_find_installation(enterprise_id, team_id, user_id, is_enterprise_install) 15 | 16 | If you would like to properly handle app uninstallations and token revocations, 17 | the following methods should be implemented. 18 | 19 | * async_delete_installation(enterprise_id, team_id, user_id) 20 | * async_delete_all(enterprise_id, team_id) 21 | 22 | If your app needs only bot scope installations, the simpler way to implement would be: 23 | 24 | * async_save(installation) 25 | * async_find_bot(enterprise_id, team_id, is_enterprise_install) 26 | * async_delete_bot(enterprise_id, team_id) 27 | * async_delete_all(enterprise_id, team_id) 28 | """ 29 | 30 | @property 31 | def logger(self) -> Logger: 32 | raise NotImplementedError() 33 | 34 | async def async_save(self, installation: Installation): 35 | """Saves a new installation data""" 36 | raise NotImplementedError() 37 | 38 | async def async_find_bot( 39 | self, 40 | *, 41 | enterprise_id: Optional[str], 42 | team_id: Optional[str], 43 | is_enterprise_install: Optional[bool] = False, 44 | ) -> Optional[Bot]: 45 | """Finds a bot scope installation per workspace / org""" 46 | raise NotImplementedError() 47 | 48 | async def async_find_installation( 49 | self, 50 | *, 51 | enterprise_id: Optional[str], 52 | team_id: Optional[str], 53 | user_id: Optional[str] = None, 54 | is_enterprise_install: Optional[bool] = False, 55 | ) -> Optional[Installation]: 56 | """Finds a relevant installation for the given IDs. 57 | If the user_id is absent, this method may return the latest installation in the workspace / org. 58 | """ 59 | raise NotImplementedError() 60 | 61 | async def async_delete_bot( 62 | self, 63 | *, 64 | enterprise_id: Optional[str], 65 | team_id: Optional[str], 66 | ) -> None: 67 | """Deletes a bot scope installation per workspace / org""" 68 | raise NotImplementedError() 69 | 70 | async def async_delete_installation( 71 | self, 72 | *, 73 | enterprise_id: Optional[str], 74 | team_id: Optional[str], 75 | user_id: Optional[str] = None, 76 | ) -> None: 77 | """Deletes an installation that matches the given IDs""" 78 | raise NotImplementedError() 79 | 80 | async def async_delete_all( 81 | self, 82 | *, 83 | enterprise_id: Optional[str], 84 | team_id: Optional[str], 85 | ): 86 | """Deletes all installation data for the given workspace / org""" 87 | await self.async_delete_bot(enterprise_id=enterprise_id, team_id=team_id) 88 | await self.async_delete_installation( 89 | enterprise_id=enterprise_id, team_id=team_id 90 | ) 91 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/models/messages/message.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import warnings 4 | from typing import Optional, Sequence 5 | 6 | from slack_sdk.models import extract_json 7 | from slack_sdk.models.attachments import Attachment 8 | from slack_sdk.models.basic_objects import ( 9 | JsonObject, 10 | JsonValidator, 11 | ) 12 | from slack_sdk.models.blocks import Block 13 | 14 | LOGGER = logging.getLogger(__name__) 15 | 16 | skip_warn = os.environ.get("SLACKCLIENT_SKIP_DEPRECATION") # for unit tests etc. 17 | if not skip_warn: 18 | message = ( 19 | "This class is no longer actively maintained. " 20 | "Please use a dict object for building message data instead." 21 | ) 22 | warnings.warn(message) 23 | 24 | 25 | class Message(JsonObject): 26 | attributes = {"text"} 27 | 28 | attachments_max_length = 100 29 | 30 | def __init__( 31 | self, 32 | *, 33 | text: str, 34 | attachments: Optional[Sequence[Attachment]] = None, 35 | blocks: Optional[Sequence[Block]] = None, 36 | markdown: bool = True, 37 | ): 38 | """ 39 | Create a message. 40 | 41 | https://api.slack.com/messaging/composing#message-structure 42 | 43 | Args: 44 | text: Plain or Slack Markdown-like text to display in the message. 45 | attachments: A list of Attachment objects to display after the rest of 46 | the message's content. More than 20 is not recommended, but the actual 47 | limit is 100 48 | blocks: A list of Block objects to attach to this message. If 49 | specified, the 'text' property is ignored (more specifically, it's used 50 | as a fallback on clients that can't render blocks) 51 | markdown: Whether to parse markdown into formatting such as 52 | bold/italics, or leave text completely unmodified. 53 | """ 54 | self.text = text 55 | self.attachments = attachments or [] 56 | self.blocks = blocks or [] 57 | self.markdown = markdown 58 | 59 | @JsonValidator( 60 | f"attachments attribute cannot exceed {attachments_max_length} items" 61 | ) 62 | def attachments_length(self): 63 | return ( 64 | self.attachments is None 65 | or len(self.attachments) <= self.attachments_max_length 66 | ) 67 | 68 | def to_dict(self) -> dict: # skipcq: PYL-W0221 69 | json = super().to_dict() 70 | if len(self.text) > 40000: 71 | LOGGER.error( 72 | "Messages over 40,000 characters are automatically truncated by Slack" 73 | ) 74 | # The following limitation used to be true in the past. 75 | # As of Feb 2021, having both is recommended 76 | # ----------------- 77 | # if self.text and self.blocks: 78 | # # Slack doesn't render the text property if there are blocks, so: 79 | # LOGGER.info(q 80 | # "text attribute is treated as fallback text if blocks are attached to " 81 | # "a message - insert text as a new SectionBlock if you want it to be " 82 | # "displayed " 83 | # ) 84 | json["attachments"] = extract_json(self.attachments) 85 | json["blocks"] = extract_json(self.blocks) 86 | json["mrkdwn"] = self.markdown 87 | return json 88 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/state_store/sqlite3/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sqlite3 3 | import time 4 | from logging import Logger 5 | from sqlite3 import Connection 6 | from uuid import uuid4 7 | 8 | from ..async_state_store import AsyncOAuthStateStore 9 | from ..state_store import OAuthStateStore 10 | 11 | 12 | class SQLite3OAuthStateStore(OAuthStateStore, AsyncOAuthStateStore): 13 | def __init__( 14 | self, 15 | *, 16 | database: str, 17 | expiration_seconds: int, 18 | logger: Logger = logging.getLogger(__name__), 19 | ): 20 | self.database = database 21 | self.expiration_seconds = expiration_seconds 22 | self.init_called = False 23 | self._logger = logger 24 | 25 | @property 26 | def logger(self) -> Logger: 27 | if self._logger is None: 28 | self._logger = logging.getLogger(__name__) 29 | return self._logger 30 | 31 | def init(self): 32 | try: 33 | with sqlite3.connect(database=self.database) as conn: 34 | cur = conn.execute("select count(1) from oauth_states;") 35 | row_num = cur.fetchone()[0] 36 | self.logger.debug( 37 | f"{row_num} oauth states are stored in {self.database}" 38 | ) 39 | except Exception: # skipcq: PYL-W0703 40 | self.create_tables() 41 | self.init_called = True 42 | 43 | def connect(self) -> Connection: 44 | if not self.init_called: 45 | self.init() 46 | return sqlite3.connect(database=self.database) 47 | 48 | def create_tables(self): 49 | with sqlite3.connect(database=self.database) as conn: 50 | conn.execute( 51 | """ 52 | create table oauth_states ( 53 | id integer primary key autoincrement, 54 | state text not null, 55 | expire_at datetime not null 56 | ); 57 | """ 58 | ) 59 | self.logger.debug(f"Tables have been created (database: {self.database})") 60 | conn.commit() 61 | 62 | async def async_issue(self, *args, **kwargs) -> str: 63 | return self.issue(*args, **kwargs) 64 | 65 | async def async_consume(self, state: str) -> bool: 66 | return self.consume(state) 67 | 68 | def issue(self, *args, **kwargs) -> str: 69 | state: str = str(uuid4()) 70 | with self.connect() as conn: 71 | parameters = [ 72 | state, 73 | time.time() + self.expiration_seconds, 74 | ] 75 | conn.execute( 76 | "insert into oauth_states (state, expire_at) values (?, ?);", parameters 77 | ) 78 | self.logger.debug( 79 | f"issue's insertion result: {parameters} (database: {self.database})" 80 | ) 81 | conn.commit() 82 | return state 83 | 84 | def consume(self, state: str) -> bool: 85 | try: 86 | with self.connect() as conn: 87 | cur = conn.execute( 88 | "select id, state from oauth_states where state = ? and expire_at > ?;", 89 | [state, time.time()], 90 | ) 91 | row = cur.fetchone() 92 | self.logger.debug( 93 | f"consume's query result: {row} (database: {self.database})" 94 | ) 95 | if row and len(row) > 0: 96 | id = row[0] # skipcq: PYL-W0622 97 | conn.execute("delete from oauth_states where id = ?;", [id]) 98 | conn.commit() 99 | return True 100 | return False 101 | except Exception as e: # skipcq: PYL-W0703 102 | message = f"Failed to find any persistent data for state: {state} - {e}" 103 | self.logger.warning(message) 104 | return False 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Cost Anomaly Detection Slack Integration 2 | The project is a plug and play solution to detect cost anomalies in your AWS accounts relying on the [AWS Cost Anomaly Detection](https://aws.amazon.com/aws-cost-management/aws-cost-anomaly-detection/) feature in Cost Explorer and push to a Slack channel. 3 | 4 | ### Solution Overview 5 | 6 | ![alt text](https://github.com/ighanim/aws-cost-anomaly-detection-slack-integration/blob/main/images/architecture-diagram-v1.1.png) 7 | 8 | 9 | ### Create a Slack Webhook URL 10 | 11 | First, create a Slack Webhook URL linked to one of your Slack channels. Follow the steps in the Slack public [documentation](https://api.slack.com/messaging/webhooks). Here is a a sample of the Slack notification: 12 | 13 | ![alt text](https://github.com/ighanim/aws-cost-anomaly-detection-slack-integration/blob/main/images/slack-notification-sample.png) 14 | 15 | ### Create Artifact store 16 | 17 | Second, create an S3 bucket to store your build artifacts -- Lambda code and CloudFormation template. For more information, see [create bucket](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/create-bucket.html). Create the bucket in the same region as the CloudFormation deployment. 18 | 19 | ### Build 20 | 21 | Third, build/package the Lambda function Python code using `zip`. For more information on the process; see [Python package](https://docs.aws.amazon.com/lambda/latest/dg/python-package.html). On your local machine or build server, run the following command. It is important to change the Lambda package version with each and everybuild so that CloudFormation can detect the change and update the Lambda function accordingly. 22 | 23 | `zip -r ./lambda-package-v1.0.0.zip lambda-code/lambda_function.py lambda-code/slack-sdk` 24 | 25 | Now, upload the Lambda package to the S3 bucket created in the first step. Use CLI, CloudFormation or API (as part of the build process) to upload the file. Here is a CLI sample command: 26 | 27 | `aws s3 cp ./lambda-package-v1.0.0.zip S3://newly-created-bucket` 28 | 29 | ### Deployment 30 | 31 | Fourth, deploy the solution using AWS CloudFormation. As a start, upload the `deployment.yml` file into the S3 bucket created in the first step. Use CLI, CloudFormation or API (as part of the build process) to upload the file. Here is a CLI sample command: 32 | 33 | `aws s3 cp ./deployment.yml S3://newly-created-bucket` 34 | 35 | > At the time being, deploy the solution in the payer account (aka Management Account). As a future enhancement, a new feature will allow the solution to deployed in any account. 36 | 37 | In AWS CloudFormation, start deploying `deployment.yml`. For more information, see [create stack](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-console-create-stack.html). CloudFormation template is accepting multiple input parameters, following are the definitions: 38 | 39 | Parameter | Description 40 | --- | --- 41 | `s3Bucket` | The name of the S3 bucket where project artifacts (Lambda package and `deployment.yml` are stored. The bucket has to be in the same region as the CloudFormation deployment 42 | `codePackage` | The name of the Lambda code package (i.e.`lambda-package-v1.0.0.zip`) 43 | `slackWebhookURL` | The Slack webhook URL 44 | `displayAccountName` | Select whether to display Account Name in the Slack Notification or not. This will require special permissions for the Lambda function to access the Organisations API. 45 | 46 | ## Configure Cost Anomaly Detection 47 | 48 | Finally, configure Cost Anomaly Detection in AWS Cost Explorer in your Payer/Management Account. To create a new cost anomaly monitor using AWS Management Console, follow the steps in the [public documentation](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/getting-started-ad.html#create-ad-alerts). Customise the steps as follows: 49 | 50 | Coniguration | Value 51 | --- | --- 52 | Monitor Type | AWS services 53 | Subscription.Alerting Frequency | Individual Alerts 54 | SNS Topic Arn | Copy the `snsTopicArn` output from the CloudFormation deployment. 55 | 56 | ## Security 57 | 58 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 59 | 60 | ## License 61 | 62 | This library is licensed under the MIT-0 License. See the LICENSE file. 63 | 64 | 65 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/cacheable_installation_store.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | from typing import Optional, Dict 3 | 4 | from slack_sdk.oauth import InstallationStore 5 | from slack_sdk.oauth.installation_store import Bot, Installation 6 | 7 | 8 | class CacheableInstallationStore(InstallationStore): 9 | underlying: InstallationStore 10 | cached_bots: Dict[str, Bot] 11 | cached_installations: Dict[str, Installation] 12 | 13 | def __init__(self, installation_store: InstallationStore): 14 | """A simple memory cache wrapper for any installation stores. 15 | 16 | Args: 17 | installation_store: The installation store to wrap 18 | """ 19 | self.underlying = installation_store 20 | self.cached_bots = {} 21 | self.cached_installations = {} 22 | 23 | @property 24 | def logger(self) -> Logger: 25 | return self.underlying.logger 26 | 27 | def save(self, installation: Installation): 28 | return self.underlying.save(installation) 29 | 30 | def find_bot( 31 | self, 32 | *, 33 | enterprise_id: Optional[str], 34 | team_id: Optional[str], 35 | is_enterprise_install: Optional[bool] = False, 36 | ) -> Optional[Bot]: 37 | if is_enterprise_install or team_id is None: 38 | team_id = "" 39 | key = f"{enterprise_id or ''}-{team_id or ''}" 40 | if key in self.cached_bots: 41 | return self.cached_bots[key] 42 | bot = self.underlying.find_bot( 43 | enterprise_id=enterprise_id, 44 | team_id=team_id, 45 | is_enterprise_install=is_enterprise_install, 46 | ) 47 | if bot: 48 | self.cached_bots[key] = bot 49 | return bot 50 | 51 | def find_installation( 52 | self, 53 | *, 54 | enterprise_id: Optional[str], 55 | team_id: Optional[str], 56 | user_id: Optional[str] = None, 57 | is_enterprise_install: Optional[bool] = False, 58 | ) -> Optional[Installation]: 59 | if is_enterprise_install or team_id is None: 60 | team_id = "" 61 | key = f"{enterprise_id or ''}-{team_id or ''}={user_id or ''}" 62 | if key in self.cached_installations: 63 | return self.cached_installations[key] 64 | installation = self.underlying.find_installation( 65 | enterprise_id=enterprise_id, 66 | team_id=team_id, 67 | user_id=user_id, 68 | is_enterprise_install=is_enterprise_install, 69 | ) 70 | if installation: 71 | self.cached_installations[key] = installation 72 | return installation 73 | 74 | def delete_bot( 75 | self, 76 | *, 77 | enterprise_id: Optional[str], 78 | team_id: Optional[str], 79 | ) -> None: 80 | self.underlying.delete_bot( 81 | enterprise_id=enterprise_id, 82 | team_id=team_id, 83 | ) 84 | key = f"{enterprise_id or ''}-{team_id or ''}" 85 | self.cached_bots.pop(key) 86 | 87 | def delete_installation( 88 | self, 89 | *, 90 | enterprise_id: Optional[str], 91 | team_id: Optional[str], 92 | user_id: Optional[str] = None, 93 | ) -> None: 94 | self.underlying.delete_installation( 95 | enterprise_id=enterprise_id, 96 | team_id=team_id, 97 | user_id=user_id, 98 | ) 99 | key = f"{enterprise_id or ''}-{team_id or ''}={user_id or ''}" 100 | self.cached_installations.pop(key) 101 | 102 | def delete_all( 103 | self, 104 | *, 105 | enterprise_id: Optional[str], 106 | team_id: Optional[str], 107 | ): 108 | self.underlying.delete_all( 109 | enterprise_id=enterprise_id, 110 | team_id=team_id, 111 | ) 112 | key_prefix = f"{enterprise_id or ''}-{team_id or ''}" 113 | for key in self.cached_bots.keys(): 114 | if key.startswith(key_prefix): 115 | self.cached_bots.pop(key) 116 | for key in self.cached_installations.keys(): 117 | if key.startswith(key_prefix): 118 | self.cached_installations.pop(key) 119 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/models/basic_objects.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from functools import wraps 3 | from typing import Callable, Iterable, Set, Union, Any 4 | 5 | from slack_sdk.errors import SlackObjectFormationError 6 | 7 | 8 | class BaseObject: 9 | """The base class for all model objects in this module""" 10 | 11 | def __str__(self): 12 | return f"" 13 | 14 | 15 | class JsonObject(BaseObject, metaclass=ABCMeta): 16 | """The base class for JSON serializable class objects""" 17 | 18 | @property 19 | @abstractmethod 20 | def attributes(self) -> Set[str]: 21 | """Provide a set of attributes of this object that will make up its JSON structure""" 22 | return set() 23 | 24 | def validate_json(self) -> None: 25 | """ 26 | Raises: 27 | SlackObjectFormationError if the object was not valid 28 | """ 29 | for attribute in (func for func in dir(self) if not func.startswith("__")): 30 | method = getattr(self, attribute, None) 31 | if callable(method) and hasattr(method, "validator"): 32 | method() 33 | 34 | def get_non_null_attributes(self) -> dict: 35 | """ 36 | Construct a dictionary out of non-null keys (from attributes property) 37 | present on this object 38 | """ 39 | 40 | def to_dict_compatible( 41 | value: Union[dict, list, object] 42 | ) -> Union[dict, list, Any]: 43 | if isinstance(value, list): # skipcq: PYL-R1705 44 | return [to_dict_compatible(v) for v in value] 45 | else: 46 | to_dict = getattr(value, "to_dict", None) 47 | if to_dict and callable(to_dict): # skipcq: PYL-R1705 48 | return { 49 | k: to_dict_compatible(v) for k, v in value.to_dict().items() # type: ignore 50 | } 51 | else: 52 | return value 53 | 54 | def is_not_empty(self, key: str) -> bool: 55 | value = getattr(self, key, None) 56 | if value is None: 57 | return False 58 | has_len = getattr(value, "__len__", None) is not None 59 | if has_len: # skipcq: PYL-R1705 60 | return len(value) > 0 61 | else: 62 | return value is not None 63 | 64 | return { 65 | key: to_dict_compatible(getattr(self, key, None)) 66 | for key in sorted(self.attributes) 67 | if is_not_empty(self, key) 68 | } 69 | 70 | def to_dict(self, *args) -> dict: 71 | """ 72 | Extract this object as a JSON-compatible, Slack-API-valid dictionary 73 | 74 | Args: 75 | *args: Any specific formatting args (rare; generally not required) 76 | 77 | Raises: 78 | SlackObjectFormationError if the object was not valid 79 | """ 80 | self.validate_json() 81 | return self.get_non_null_attributes() 82 | 83 | def __repr__(self): 84 | dict_value = self.get_non_null_attributes() 85 | if dict_value: # skipcq: PYL-R1705 86 | return f"" 87 | else: 88 | return self.__str__() 89 | 90 | 91 | class JsonValidator: 92 | def __init__(self, message: str): 93 | """ 94 | Decorate a method on a class to mark it as a JSON validator. Validation 95 | functions should return true if valid, false if not. 96 | 97 | Args: 98 | message: Message to be attached to the thrown SlackObjectFormationError 99 | """ 100 | self.message = message 101 | 102 | def __call__(self, func: Callable) -> Callable[..., None]: 103 | @wraps(func) 104 | def wrapped_f(*args, **kwargs): 105 | if not func(*args, **kwargs): 106 | raise SlackObjectFormationError(self.message) 107 | 108 | wrapped_f.validator = True 109 | return wrapped_f 110 | 111 | 112 | class EnumValidator(JsonValidator): 113 | def __init__(self, attribute: str, enum: Iterable[str]): 114 | super().__init__( 115 | f"{attribute} attribute must be one of the following values: " 116 | f"{', '.join(enum)}" 117 | ) 118 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/web/async_internal_utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from asyncio import AbstractEventLoop 5 | from logging import Logger 6 | from typing import Optional, BinaryIO, Dict, Sequence, Union 7 | 8 | import aiohttp 9 | from aiohttp import ClientSession 10 | 11 | from slack_sdk.errors import SlackApiError 12 | from slack_sdk.web.internal_utils import _build_unexpected_body_error_message 13 | 14 | 15 | def _get_event_loop() -> AbstractEventLoop: 16 | """Retrieves the event loop or creates a new one.""" 17 | try: 18 | return asyncio.get_event_loop() 19 | except RuntimeError: 20 | loop = asyncio.new_event_loop() 21 | asyncio.set_event_loop(loop) 22 | return loop 23 | 24 | 25 | def _files_to_data(req_args: dict) -> Sequence[BinaryIO]: 26 | open_files = [] 27 | files = req_args.pop("files", None) 28 | if files is not None: 29 | for k, v in files.items(): 30 | if isinstance(v, str): 31 | f = open(v.encode("utf-8", "ignore"), "rb") 32 | open_files.append(f) 33 | req_args["data"].update({k: f}) 34 | else: 35 | req_args["data"].update({k: v}) 36 | return open_files 37 | 38 | 39 | async def _request_with_session( 40 | *, 41 | current_session: Optional[ClientSession], 42 | timeout: int, 43 | logger: Logger, 44 | http_verb: str, 45 | api_url: str, 46 | req_args: dict, 47 | ) -> Dict[str, any]: 48 | """Submit the HTTP request with the running session or a new session. 49 | Returns: 50 | A dictionary of the response data. 51 | """ 52 | session = None 53 | use_running_session = current_session and not current_session.closed 54 | if use_running_session: 55 | session = current_session 56 | else: 57 | session = aiohttp.ClientSession( 58 | timeout=aiohttp.ClientTimeout(total=timeout), 59 | auth=req_args.pop("auth", None), 60 | ) 61 | 62 | if logger.level <= logging.DEBUG: 63 | 64 | def convert_params(values: dict) -> dict: 65 | if not values or not isinstance(values, dict): 66 | return {} 67 | return { 68 | k: ("(bytes)" if isinstance(v, bytes) else v) for k, v in values.items() 69 | } 70 | 71 | headers = { 72 | k: "(redacted)" if k.lower() == "authorization" else v 73 | for k, v in req_args.get("headers", {}).items() 74 | } 75 | logger.debug( 76 | f"Sending a request - url: {http_verb} {api_url}, " 77 | f"params: {convert_params(req_args.get('params'))}, " 78 | f"files: {convert_params(req_args.get('files'))}, " 79 | f"data: {convert_params(req_args.get('data'))}, " 80 | f"json: {convert_params(req_args.get('json'))}, " 81 | f"proxy: {convert_params(req_args.get('proxy'))}, " 82 | f"headers: {headers}" 83 | ) 84 | 85 | response = None 86 | try: 87 | async with session.request(http_verb, api_url, **req_args) as res: 88 | data: Union[dict, bytes] = {} 89 | if res.content_type == "application/gzip": 90 | # admin.analytics.getFile 91 | data = await res.read() 92 | else: 93 | try: 94 | data = await res.json() 95 | except aiohttp.ContentTypeError: 96 | logger.debug( 97 | f"No response data returned from the following API call: {api_url}." 98 | ) 99 | except json.decoder.JSONDecodeError: 100 | try: 101 | body: str = await res.text() 102 | message = _build_unexpected_body_error_message(body) 103 | raise SlackApiError(message, res) 104 | except Exception as e: 105 | raise SlackApiError( 106 | f"Unexpectedly failed to read the response body: {str(e)}", 107 | res, 108 | ) 109 | 110 | response = { 111 | "data": data, 112 | "headers": res.headers, 113 | "status_code": res.status, 114 | } 115 | finally: 116 | if not use_running_session: 117 | await session.close() 118 | return response 119 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/async_cacheable_installation_store.py: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | from typing import Optional, Dict 3 | 4 | from slack_sdk.oauth.installation_store import Bot, Installation 5 | from slack_sdk.oauth.installation_store.async_installation_store import ( 6 | AsyncInstallationStore, 7 | ) 8 | 9 | 10 | class AsyncCacheableInstallationStore(AsyncInstallationStore): 11 | underlying: AsyncInstallationStore 12 | cached_bots: Dict[str, Bot] 13 | cached_installations: Dict[str, Installation] 14 | 15 | def __init__(self, installation_store: AsyncInstallationStore): 16 | """A simple memory cache wrapper for any installation stores. 17 | 18 | Args: 19 | installation_store: The installation store to wrap 20 | """ 21 | self.underlying = installation_store 22 | self.cached_bots = {} 23 | self.cached_installations = {} 24 | 25 | @property 26 | def logger(self) -> Logger: 27 | return self.underlying.logger 28 | 29 | async def async_save(self, installation: Installation): 30 | return await self.underlying.async_save(installation) 31 | 32 | async def async_find_bot( 33 | self, 34 | *, 35 | enterprise_id: Optional[str], 36 | team_id: Optional[str], 37 | is_enterprise_install: Optional[bool] = False, 38 | ) -> Optional[Bot]: 39 | if is_enterprise_install or team_id is None: 40 | team_id = "" 41 | key = f"{enterprise_id or ''}-{team_id or ''}" 42 | if key in self.cached_bots: 43 | return self.cached_bots[key] 44 | bot = await self.underlying.async_find_bot( 45 | enterprise_id=enterprise_id, 46 | team_id=team_id, 47 | is_enterprise_install=is_enterprise_install, 48 | ) 49 | if bot: 50 | self.cached_bots[key] = bot 51 | return bot 52 | 53 | async def async_find_installation( 54 | self, 55 | *, 56 | enterprise_id: Optional[str], 57 | team_id: Optional[str], 58 | user_id: Optional[str] = None, 59 | is_enterprise_install: Optional[bool] = False, 60 | ) -> Optional[Installation]: 61 | if is_enterprise_install or team_id is None: 62 | team_id = "" 63 | key = f"{enterprise_id or ''}-{team_id or ''}-{user_id or ''}" 64 | if key in self.cached_installations: 65 | return self.cached_installations[key] 66 | installation = await self.underlying.async_find_installation( 67 | enterprise_id=enterprise_id, 68 | team_id=team_id, 69 | user_id=user_id, 70 | is_enterprise_install=is_enterprise_install, 71 | ) 72 | if installation: 73 | self.cached_installations[key] = installation 74 | return installation 75 | 76 | async def async_delete_bot( 77 | self, 78 | *, 79 | enterprise_id: Optional[str], 80 | team_id: Optional[str], 81 | ) -> None: 82 | await self.underlying.async_delete_bot( 83 | enterprise_id=enterprise_id, 84 | team_id=team_id, 85 | ) 86 | key = f"{enterprise_id or ''}-{team_id or ''}" 87 | self.cached_bots.pop(key) 88 | 89 | async def async_delete_installation( 90 | self, 91 | *, 92 | enterprise_id: Optional[str], 93 | team_id: Optional[str], 94 | user_id: Optional[str] = None, 95 | ) -> None: 96 | await self.underlying.async_delete_installation( 97 | enterprise_id=enterprise_id, 98 | team_id=team_id, 99 | user_id=user_id, 100 | ) 101 | key = f"{enterprise_id or ''}-{team_id or ''}={user_id or ''}" 102 | self.cached_installations.pop(key) 103 | 104 | async def async_delete_all( 105 | self, 106 | *, 107 | enterprise_id: Optional[str], 108 | team_id: Optional[str], 109 | ): 110 | await self.underlying.async_delete_all( 111 | enterprise_id=enterprise_id, 112 | team_id=team_id, 113 | ) 114 | key_prefix = f"{enterprise_id or ''}-{team_id or ''}" 115 | for key in self.cached_bots.keys(): 116 | if key.startswith(key_prefix): 117 | self.cached_bots.pop(key) 118 | for key in self.cached_installations.keys(): 119 | if key.startswith(key_prefix): 120 | self.cached_installations.pop(key) 121 | -------------------------------------------------------------------------------- /MODERNIZATION-NOTES-2025.md: -------------------------------------------------------------------------------- 1 | # AWS Cost Anomaly Detection Slack Integration - 2025 Modernization Notes 2 | 3 | ## Overview 4 | This document outlines the critical updates needed to modernize the AWS Cost Anomaly Detection Slack Integration repository (originally created in 2021) for 2025 compatibility. 5 | 6 | ## Critical Issues Identified 7 | 8 | ### 1. **Lambda Runtime (CRITICAL - BREAKING)** 9 | **Issue**: Currently uses `python3.7` which is deprecated and no longer supported by AWS Lambda. 10 | **Impact**: Deployments will fail as Python 3.7 runtime is no longer available. 11 | **Fix**: Update to `python3.12` or `python3.13` 12 | 13 | **Changes needed in `deployment.yml`:** 14 | ```yaml 15 | # Line 132 - Change from: 16 | Runtime: python3.7 17 | # To: 18 | Runtime: python3.12 19 | ``` 20 | 21 | ### 2. **AppConfig Layer ARNs (CRITICAL - BREAKING)** 22 | **Issue**: All AppConfig layer ARNs in the CloudFormation template are severely outdated (version 44 vs current 207). 23 | **Impact**: Lambda function will fail to access AppConfig, breaking feature flag functionality. 24 | **Fix**: Update all layer ARNs to version 2.0.2037 (latest as of May 2025) 25 | 26 | **Example for us-east-1:** 27 | ```yaml 28 | # Change from: 29 | AppConfigLayerArn: arn:aws:lambda:us-east-1:027255383542:layer:AWS-AppConfig-Extension:44 30 | # To: 31 | AppConfigLayerArn: arn:aws:lambda:us-east-1:027255383542:layer:AWS-AppConfig-Extension:207 32 | ``` 33 | 34 | ### 3. **IAM Permissions (CRITICAL - BREAKING)** 35 | **Issue**: Missing required AppConfig permissions for new API endpoints. 36 | **Impact**: Lambda function cannot retrieve configuration data from AppConfig. 37 | **Fix**: Add new permissions to `ReadAppConfigLambdaPolicy` 38 | 39 | **Changes needed in `deployment.yml`:** 40 | ```yaml 41 | # In ReadAppConfigLambdaPolicy, change from: 42 | - appconfig:GetConfiguration 43 | # To: 44 | - appconfig:StartConfigurationSession 45 | - appconfig:GetLatestConfiguration 46 | ``` 47 | 48 | ### 4. **Lambda Function Code (IMPORTANT)** 49 | **Issue**: No error handling, deprecated practices, potential timeout issues. 50 | **Impact**: Function may fail silently or timeout on external calls. 51 | **Fix**: Enhanced error handling and logging (see `lambda_function_updated.py`) 52 | 53 | ## Files That Need Updates 54 | 55 | ### 1. `deployment.yml` 56 | - Update Lambda runtime to `python3.12` 57 | - Update all AppConfig layer ARNs (complete mapping provided in `deployment-updated.yml`) 58 | - Update IAM permissions for AppConfig v2 APIs 59 | 60 | ### 2. `lambda-code/lambda_function.py` 61 | - Add proper error handling and logging 62 | - Add timeout handling for external API calls 63 | - Improve exception handling for AppConfig and Secrets Manager calls 64 | 65 | ### 3. `README.md` (Suggested additions) 66 | Add a "2025 Update Notes" section with: 67 | - Note about Python 3.12 requirement 68 | - Updated build command referencing new package version 69 | - Troubleshooting section for common migration issues 70 | 71 | ## Quick Fix Summary 72 | 73 | For immediate deployment compatibility: 74 | 75 | 1. **Replace runtime**: Change `python3.7` to `python3.12` in CloudFormation template 76 | 2. **Update layer ARNs**: Use the complete mapping from `deployment-updated.yml` 77 | 3. **Fix IAM permissions**: Replace deprecated AppConfig permissions 78 | 4. **Enhanced Lambda code**: Use improved error handling from `lambda_function_updated.py` 79 | 80 | ## Testing Recommendations 81 | 82 | After applying updates: 83 | 1. Deploy to a test environment first 84 | 2. Verify AppConfig layer loads correctly 85 | 3. Test with a sample Cost Anomaly event 86 | 4. Confirm Slack notifications are received 87 | 5. Check CloudWatch logs for any errors 88 | 89 | ## Backward Compatibility 90 | 91 | These changes are **breaking changes** due to: 92 | - Deprecated Python runtime no longer available 93 | - Old AppConfig API endpoints removed 94 | - Updated layer versions required 95 | 96 | ## Additional Improvements (Optional) 97 | 98 | The updated files also include: 99 | - Better error handling and logging 100 | - Timeout protection for external calls 101 | - Graceful fallback when AppConfig is unavailable 102 | - Modern Python coding practices 103 | - Enhanced security practices 104 | 105 | ## Files Provided 106 | 107 | 1. `deployment-updated.yml` - Complete updated CloudFormation template 108 | 2. `lambda_function_updated.py` - Enhanced Lambda function with error handling 109 | 3. This documentation file 110 | 111 | ## Migration Path 112 | 113 | 1. **Immediate**: Apply the critical fixes to existing files 114 | 2. **Recommended**: Use the complete updated files for best practices 115 | 3. **Testing**: Validate in non-production environment first 116 | 4. **Deployment**: Update production after successful testing 117 | 118 | --- 119 | 120 | **Note**: These updates maintain the original functionality while ensuring compatibility with current AWS services and best practices as of 2025. 121 | -------------------------------------------------------------------------------- /CRITICAL-FIXES.patch: -------------------------------------------------------------------------------- 1 | # Critical Fixes Patch for AWS Cost Anomaly Detection Slack Integration 2 | # Apply these minimal changes to make the 2021 code work in 2025 3 | 4 | ## File: deployment.yml 5 | 6 | ### Fix 1: Update Lambda Runtime (Line ~132) 7 | - Runtime: python3.7 8 | + Runtime: python3.12 9 | 10 | ### Fix 2: Update IAM Permissions for AppConfig (Lines ~50-56) 11 | Replace the ReadAppConfigLambdaPolicy Statement with: 12 | ```yaml 13 | Statement: 14 | - Effect: Allow 15 | Action: 16 | - appconfig:StartConfigurationSession 17 | - appconfig:GetLatestConfiguration 18 | Resource: "*" 19 | ``` 20 | 21 | ### Fix 3: Update AppConfig Layer ARNs (Lines ~280-340) 22 | Replace the entire AppConfigLayerArn mapping with: 23 | 24 | ```yaml 25 | Mappings: 26 | AppConfigLayerArn: 27 | us-east-1: 28 | AppConfigLayerArn: arn:aws:lambda:us-east-1:027255383542:layer:AWS-AppConfig-Extension:207 29 | us-east-2: 30 | AppConfigLayerArn: arn:aws:lambda:us-east-2:728743619870:layer:AWS-AppConfig-Extension:162 31 | us-west-1: 32 | AppConfigLayerArn: arn:aws:lambda:us-west-1:958113053741:layer:AWS-AppConfig-Extension:258 33 | us-west-2: 34 | AppConfigLayerArn: arn:aws:lambda:us-west-2:359756378197:layer:AWS-AppConfig-Extension:262 35 | ap-southeast-2: 36 | AppConfigLayerArn: arn:aws:lambda:ap-southeast-2:080788657173:layer:AWS-AppConfig-Extension:199 37 | ca-central-1: 38 | AppConfigLayerArn: arn:aws:lambda:ca-central-1:039592058896:layer:AWS-AppConfig-Extension:152 39 | eu-central-1: 40 | AppConfigLayerArn: arn:aws:lambda:eu-central-1:066940009817:layer:AWS-AppConfig-Extension:189 41 | eu-west-1: 42 | AppConfigLayerArn: arn:aws:lambda:eu-west-1:434848589818:layer:AWS-AppConfig-Extension:189 43 | eu-west-2: 44 | AppConfigLayerArn: arn:aws:lambda:eu-west-2:282860088358:layer:AWS-AppConfig-Extension:133 45 | eu-west-3: 46 | AppConfigLayerArn: arn:aws:lambda:eu-west-3:493207061005:layer:AWS-AppConfig-Extension:162 47 | eu-north-1: 48 | AppConfigLayerArn: arn:aws:lambda:eu-north-1:646970417810:layer:AWS-AppConfig-Extension:259 49 | eu-south-1: 50 | AppConfigLayerArn: arn:aws:lambda:eu-south-1:203683718741:layer:AWS-AppConfig-Extension:140 51 | cn-north-1: 52 | AppConfigLayerArn: arn:aws-cn:lambda:cn-north-1:615057806174:layer:AWS-AppConfig-Extension:133 53 | cn-northwest-1: 54 | AppConfigLayerArn: arn:aws-cn:lambda:cn-northwest-1:615084187847:layer:AWS-AppConfig-Extension:131 55 | ap-east-1: 56 | AppConfigLayerArn: arn:aws:lambda:ap-east-1:630222743974:layer:AWS-AppConfig-Extension:142 57 | ap-northeast-1: 58 | AppConfigLayerArn: arn:aws:lambda:ap-northeast-1:980059726660:layer:AWS-AppConfig-Extension:155 59 | ap-northeast-3: 60 | AppConfigLayerArn: arn:aws:lambda:ap-northeast-3:706869817123:layer:AWS-AppConfig-Extension:159 61 | ap-northeast-2: 62 | AppConfigLayerArn: arn:aws:lambda:ap-northeast-2:826293736237:layer:AWS-AppConfig-Extension:165 63 | ap-southeast-1: 64 | AppConfigLayerArn: arn:aws:lambda:ap-southeast-1:421114256042:layer:AWS-AppConfig-Extension:156 65 | ap-south-1: 66 | AppConfigLayerArn: arn:aws:lambda:ap-south-1:554480029851:layer:AWS-AppConfig-Extension:175 67 | sa-east-1: 68 | AppConfigLayerArn: arn:aws:lambda:sa-east-1:000010852771:layer:AWS-AppConfig-Extension:215 69 | af-south-1: 70 | AppConfigLayerArn: arn:aws:lambda:af-south-1:574348263942:layer:AWS-AppConfig-Extension:152 71 | me-south-1: 72 | AppConfigLayerArn: arn:aws:lambda:me-south-1:559955524753:layer:AWS-AppConfig-Extension:154 73 | us-gov-east-1: 74 | AppConfigLayerArn: arn:aws-us-gov:lambda:us-gov-east-1:946561847325:layer:AWS-AppConfig-Extension:110 75 | us-gov-west-1: 76 | AppConfigLayerArn: arn:aws-us-gov:lambda:us-gov-west-1:946746059096:layer:AWS-AppConfig-Extension:110 77 | ``` 78 | 79 | ## File: lambda-code/lambda_function.py 80 | 81 | ### Optional but Recommended: Add error handling around AppConfig call (Line ~108) 82 | Replace: 83 | ```python 84 | def get_application_features(): 85 | url = f'http://localhost:2772/applications/cost-anomaly-to-slack-application/environments/cost-anomaly-to-slack-environment/configurations/cost-anomaly-to-slack-configuration-profile' 86 | config = urllib.request.urlopen(url).read() 87 | return config 88 | ``` 89 | 90 | With: 91 | ```python 92 | def get_application_features(): 93 | url = f'http://localhost:2772/applications/cost-anomaly-to-slack-application/environments/cost-anomaly-to-slack-environment/configurations/cost-anomaly-to-slack-configuration-profile' 94 | try: 95 | config = urllib.request.urlopen(url, timeout=10).read() 96 | return config 97 | except Exception as e: 98 | print(f"Error retrieving AppConfig: {e}") 99 | # Return default configuration if AppConfig fails 100 | return b'{"feature-flags": {"displayAccountName": false}}' 101 | ``` 102 | 103 | ## Summary 104 | These are the MINIMUM changes needed to make the 2021 code work in 2025: 105 | 1. Update Python runtime to 3.12 106 | 2. Fix AppConfig IAM permissions 107 | 3. Update AppConfig layer ARNs to current versions 108 | 4. (Optional) Add basic error handling to prevent failures 109 | 110 | The complete updated files (deployment-updated.yml and lambda_function_updated.py) provide additional improvements but these patches will restore basic functionality. 111 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/scim/v1/internal_utils.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import re 4 | import sys 5 | from typing import Dict, Callable 6 | from typing import Union, Optional, Any 7 | from urllib.parse import quote 8 | 9 | from .default_arg import DefaultArg, NotGiven 10 | from slack_sdk.web.internal_utils import get_user_agent 11 | 12 | 13 | def _build_query(params: Optional[Dict[str, Any]]) -> str: 14 | if params is not None and len(params) > 0: 15 | return "&".join( 16 | { 17 | f"{quote(str(k))}={quote(str(v))}" 18 | for k, v in params.items() 19 | if v is not None 20 | } 21 | ) 22 | return "" 23 | 24 | 25 | def _is_iterable(obj: Union[Optional[Any], DefaultArg]) -> bool: 26 | return obj is not None and obj is not NotGiven 27 | 28 | 29 | def _to_dict_without_not_given(obj: Any) -> dict: 30 | dict_value = {} 31 | given_dict = obj if isinstance(obj, dict) else vars(obj) 32 | for key, value in given_dict.items(): 33 | if key == "unknown_fields": 34 | if value is not None: 35 | converted = _to_dict_without_not_given(value) 36 | dict_value.update(converted) 37 | continue 38 | 39 | dict_key = _to_camel_case_key(key) 40 | if value is NotGiven: 41 | continue 42 | if isinstance(value, list): 43 | dict_value[dict_key] = [ 44 | elem.to_dict() if hasattr(elem, "to_dict") else elem for elem in value 45 | ] 46 | elif isinstance(value, dict): 47 | dict_value[dict_key] = _to_dict_without_not_given(value) 48 | else: 49 | dict_value[dict_key] = ( 50 | value.to_dict() if hasattr(value, "to_dict") else value 51 | ) 52 | return dict_value 53 | 54 | 55 | def _create_copy(original: Any) -> Any: 56 | if sys.version_info.major == 3 and sys.version_info.minor <= 6: 57 | return copy.copy(original) 58 | else: 59 | return copy.deepcopy(original) 60 | 61 | 62 | def _to_camel_case_key(key: str) -> str: 63 | next_to_capital = False 64 | result = "" 65 | for c in key: 66 | if c == "_": 67 | next_to_capital = True 68 | elif next_to_capital: 69 | result += c.upper() 70 | next_to_capital = False 71 | else: 72 | result += c 73 | return result 74 | 75 | 76 | def _to_snake_cased(original: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: 77 | return _convert_dict_keys( 78 | original, 79 | {}, 80 | lambda s: re.sub( 81 | "^_", 82 | "", 83 | "".join(["_" + c.lower() if c.isupper() else c for c in s]), 84 | ), 85 | ) 86 | 87 | 88 | def _to_camel_cased(original: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: 89 | return _convert_dict_keys( 90 | original, 91 | {}, 92 | _to_camel_case_key, 93 | ) 94 | 95 | 96 | def _convert_dict_keys( 97 | original_dict: Optional[Dict[str, Any]], 98 | result_dict: Dict[str, Any], 99 | convert: Callable[[str], str], 100 | ) -> Optional[Dict[str, Any]]: 101 | if original_dict is None: 102 | return result_dict 103 | 104 | for original_key, original_value in original_dict.items(): 105 | new_key = convert(original_key) 106 | if isinstance(original_value, dict): 107 | result_dict[new_key] = {} 108 | new_value = _convert_dict_keys( 109 | original_value, result_dict[new_key], convert 110 | ) 111 | result_dict[new_key] = new_value 112 | elif isinstance(original_value, list): 113 | result_dict[new_key] = [] 114 | is_dict = len(original_value) > 0 and isinstance(original_value[0], dict) 115 | for element in original_value: 116 | if is_dict: 117 | if isinstance(element, dict): 118 | new_element = {} 119 | for elem_key, elem_value in element.items(): 120 | new_element[convert(elem_key)] = ( 121 | _convert_dict_keys(elem_value, {}, convert) 122 | if isinstance(elem_value, dict) 123 | else _create_copy(elem_value) 124 | ) 125 | result_dict[new_key].append(new_element) 126 | else: 127 | result_dict[new_key].append(_create_copy(original_value)) 128 | else: 129 | result_dict[new_key] = _create_copy(original_value) 130 | return result_dict 131 | 132 | 133 | def _build_request_headers( 134 | token: str, 135 | default_headers: Dict[str, str], 136 | additional_headers: Optional[Dict[str, str]], 137 | ) -> Dict[str, str]: 138 | request_headers = { 139 | "Content-Type": "application/json;charset=utf-8", 140 | "Authorization": f"Bearer {token}", 141 | } 142 | if default_headers is None or "User-Agent" not in default_headers: 143 | request_headers["User-Agent"] = get_user_agent() 144 | if default_headers is not None: 145 | request_headers.update(default_headers) 146 | if additional_headers is not None: 147 | request_headers.update(additional_headers) 148 | return request_headers 149 | 150 | 151 | def _debug_log_response(logger, resp: "SCIMResponse") -> None: # noqa: F821 152 | if logger.level <= logging.DEBUG: 153 | logger.debug( 154 | "Received the following response - " 155 | f"status: {resp.status_code}, " 156 | f"headers: {(dict(resp.headers))}, " 157 | f"body: {resp.raw_body}" 158 | ) 159 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/async_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from asyncio import Queue 5 | from asyncio.futures import Future 6 | from logging import Logger 7 | from typing import Dict, Union, Any, Optional, List, Callable, Awaitable 8 | 9 | from slack_sdk.errors import SlackApiError 10 | from slack_sdk.socket_mode.async_listeners import ( 11 | AsyncWebSocketMessageListener, 12 | AsyncSocketModeRequestListener, 13 | ) 14 | from slack_sdk.socket_mode.request import SocketModeRequest 15 | from slack_sdk.socket_mode.response import SocketModeResponse 16 | from slack_sdk.web.async_client import AsyncWebClient 17 | 18 | 19 | class AsyncBaseSocketModeClient: 20 | logger: Logger 21 | web_client: AsyncWebClient 22 | app_token: str 23 | wss_uri: str 24 | auto_reconnect_enabled: bool 25 | closed: bool 26 | message_queue: Queue 27 | message_listeners: List[ 28 | Union[ 29 | AsyncWebSocketMessageListener, 30 | Callable[ 31 | ["AsyncBaseSocketModeClient", dict, Optional[str]], Awaitable[None] 32 | ], 33 | ] 34 | ] 35 | socket_mode_request_listeners: List[ 36 | Union[ 37 | AsyncSocketModeRequestListener, 38 | Callable[["AsyncBaseSocketModeClient", SocketModeRequest], Awaitable[None]], 39 | ] 40 | ] 41 | 42 | async def issue_new_wss_url(self) -> str: 43 | try: 44 | response = await self.web_client.apps_connections_open( 45 | app_token=self.app_token 46 | ) 47 | return response["url"] 48 | except SlackApiError as e: 49 | if e.response["error"] == "ratelimited": 50 | # NOTE: ratelimited errors rarely occur with this endpoint 51 | delay = int(e.response.headers.get("Retry-After", "30")) # Tier1 52 | self.logger.info(f"Rate limited. Retrying in {delay} seconds...") 53 | await asyncio.sleep(delay) 54 | # Retry to issue a new WSS URL 55 | return await self.issue_new_wss_url() 56 | else: 57 | # other errors 58 | self.logger.error(f"Failed to retrieve WSS URL: {e}") 59 | raise e 60 | 61 | async def connect(self): 62 | raise NotImplementedError() 63 | 64 | async def disconnect(self): 65 | raise NotImplementedError() 66 | 67 | async def connect_to_new_endpoint(self): 68 | self.wss_uri = await self.issue_new_wss_url() 69 | await self.connect() 70 | 71 | async def close(self): 72 | self.closed = True 73 | await self.disconnect() 74 | 75 | async def send_message(self, message: str): 76 | raise NotImplementedError() 77 | 78 | async def send_socket_mode_response( 79 | self, response: Union[Dict[str, Any], SocketModeResponse] 80 | ): 81 | if isinstance(response, SocketModeResponse): 82 | await self.send_message(json.dumps(response.to_dict())) 83 | else: 84 | await self.send_message(json.dumps(response)) 85 | 86 | async def enqueue_message(self, message: str): 87 | await self.message_queue.put(message) 88 | if self.logger.level <= logging.DEBUG: 89 | queue_size = self.message_queue.qsize() 90 | self.logger.debug( 91 | f"A new message enqueued (current queue size: {queue_size})" 92 | ) 93 | 94 | async def process_messages(self): 95 | while not self.closed: 96 | try: 97 | await self.process_message() 98 | except Exception as e: 99 | self.logger.exception(f"Failed to process a message: {e}") 100 | 101 | async def process_message(self): 102 | raw_message = await self.message_queue.get() 103 | if raw_message is not None: 104 | message: dict = {} 105 | if raw_message.startswith("{"): 106 | message = json.loads(raw_message) 107 | _: Future[None] = asyncio.ensure_future( 108 | self.run_message_listeners(message, raw_message) 109 | ) 110 | 111 | async def run_message_listeners(self, message: dict, raw_message: str) -> None: 112 | type, envelope_id = message.get("type"), message.get("envelope_id") 113 | if self.logger.level <= logging.DEBUG: 114 | self.logger.debug( 115 | f"Message processing started (type: {type}, envelope_id: {envelope_id})" 116 | ) 117 | try: 118 | if message.get("type") == "disconnect": 119 | await self.connect_to_new_endpoint() 120 | return 121 | 122 | for listener in self.message_listeners: 123 | try: 124 | await listener(self, message, raw_message) 125 | except Exception as e: 126 | self.logger.exception(f"Failed to run a message listener: {e}") 127 | 128 | if len(self.socket_mode_request_listeners) > 0: 129 | request = SocketModeRequest.from_dict(message) 130 | if request is not None: 131 | for listener in self.socket_mode_request_listeners: 132 | try: 133 | await listener(self, request) 134 | except Exception as e: 135 | self.logger.exception( 136 | f"Failed to run a request listener: {e}" 137 | ) 138 | except Exception as e: 139 | self.logger.exception(f"Failed to run message listeners: {e}") 140 | finally: 141 | if self.logger.level <= logging.DEBUG: 142 | self.logger.debug( 143 | f"Message processing completed (type: {type}, envelope_id: {envelope_id})" 144 | ) 145 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/models/installation.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from time import time 3 | from typing import Optional, Union, Dict, Any, Sequence 4 | 5 | from slack_sdk.oauth.installation_store.models.bot import Bot 6 | 7 | 8 | class Installation: 9 | app_id: Optional[str] 10 | enterprise_id: Optional[str] 11 | enterprise_name: Optional[str] 12 | enterprise_url: Optional[str] 13 | team_id: Optional[str] 14 | team_name: Optional[str] 15 | bot_token: Optional[str] 16 | bot_id: Optional[str] 17 | bot_user_id: Optional[str] 18 | bot_scopes: Optional[Sequence[str]] 19 | user_id: str 20 | user_token: Optional[str] 21 | user_scopes: Optional[Sequence[str]] 22 | incoming_webhook_url: Optional[str] 23 | incoming_webhook_channel: Optional[str] 24 | incoming_webhook_channel_id: Optional[str] 25 | incoming_webhook_configuration_url: Optional[str] 26 | is_enterprise_install: bool 27 | token_type: Optional[str] 28 | installed_at: float 29 | 30 | custom_values: Dict[str, Any] 31 | 32 | def __init__( 33 | self, 34 | *, 35 | app_id: Optional[str] = None, 36 | # org / workspace 37 | enterprise_id: Optional[str] = None, 38 | enterprise_name: Optional[str] = None, 39 | enterprise_url: Optional[str] = None, 40 | team_id: Optional[str] = None, 41 | team_name: Optional[str] = None, 42 | # bot 43 | bot_token: Optional[str] = None, 44 | bot_id: Optional[str] = None, 45 | bot_user_id: Optional[str] = None, 46 | bot_scopes: Union[str, Sequence[str]] = "", 47 | # installer 48 | user_id: str, 49 | user_token: Optional[str] = None, 50 | user_scopes: Union[str, Sequence[str]] = "", 51 | # incoming webhook 52 | incoming_webhook_url: Optional[str] = None, 53 | incoming_webhook_channel: Optional[str] = None, 54 | incoming_webhook_channel_id: Optional[str] = None, 55 | incoming_webhook_configuration_url: Optional[str] = None, 56 | # org app 57 | is_enterprise_install: Optional[bool] = False, 58 | token_type: Optional[str] = None, 59 | # timestamps 60 | installed_at: Optional[float] = None, 61 | # custom values 62 | custom_values: Optional[Dict[str, Any]] = None 63 | ): 64 | self.app_id = app_id 65 | self.enterprise_id = enterprise_id 66 | self.enterprise_name = enterprise_name 67 | self.enterprise_url = enterprise_url 68 | self.team_id = team_id 69 | self.team_name = team_name 70 | self.bot_token = bot_token 71 | self.bot_id = bot_id 72 | self.bot_user_id = bot_user_id 73 | if isinstance(bot_scopes, str): 74 | self.bot_scopes = bot_scopes.split(",") if len(bot_scopes) > 0 else [] 75 | else: 76 | self.bot_scopes = bot_scopes 77 | 78 | self.user_id = user_id 79 | self.user_token = user_token 80 | if isinstance(user_scopes, str): 81 | self.user_scopes = user_scopes.split(",") if len(user_scopes) > 0 else [] 82 | else: 83 | self.user_scopes = user_scopes 84 | 85 | self.incoming_webhook_url = incoming_webhook_url 86 | self.incoming_webhook_channel = incoming_webhook_channel 87 | self.incoming_webhook_channel_id = incoming_webhook_channel_id 88 | self.incoming_webhook_configuration_url = incoming_webhook_configuration_url 89 | 90 | self.is_enterprise_install = is_enterprise_install or False 91 | self.token_type = token_type 92 | 93 | self.installed_at = time() if installed_at is None else installed_at 94 | self.custom_values = custom_values if custom_values is not None else {} 95 | 96 | def to_bot(self) -> Bot: 97 | return Bot( 98 | app_id=self.app_id, 99 | enterprise_id=self.enterprise_id, 100 | enterprise_name=self.enterprise_name, 101 | team_id=self.team_id, 102 | team_name=self.team_name, 103 | bot_token=self.bot_token, 104 | bot_id=self.bot_id, 105 | bot_user_id=self.bot_user_id, 106 | bot_scopes=self.bot_scopes, 107 | is_enterprise_install=self.is_enterprise_install, 108 | installed_at=self.installed_at, 109 | custom_values=self.custom_values, 110 | ) 111 | 112 | def set_custom_value(self, name: str, value: Any): 113 | self.custom_values[name] = value 114 | 115 | def get_custom_value(self, name: str) -> Optional[Any]: 116 | return self.custom_values.get(name) 117 | 118 | def to_dict(self) -> Dict[str, Any]: 119 | standard_values = { 120 | "app_id": self.app_id, 121 | "enterprise_id": self.enterprise_id, 122 | "enterprise_name": self.enterprise_name, 123 | "enterprise_url": self.enterprise_url, 124 | "team_id": self.team_id, 125 | "team_name": self.team_name, 126 | "bot_token": self.bot_token, 127 | "bot_id": self.bot_id, 128 | "bot_user_id": self.bot_user_id, 129 | "bot_scopes": ",".join(self.bot_scopes) if self.bot_scopes else None, 130 | "user_id": self.user_id, 131 | "user_token": self.user_token, 132 | "user_scopes": ",".join(self.user_scopes) if self.user_scopes else None, 133 | "incoming_webhook_url": self.incoming_webhook_url, 134 | "incoming_webhook_channel": self.incoming_webhook_channel, 135 | "incoming_webhook_channel_id": self.incoming_webhook_channel_id, 136 | "incoming_webhook_configuration_url": self.incoming_webhook_configuration_url, 137 | "is_enterprise_install": self.is_enterprise_install, 138 | "token_type": self.token_type, 139 | "installed_at": datetime.utcfromtimestamp(self.installed_at), 140 | } 141 | # prioritize standard_values over custom_values 142 | # when the same keys exist in both 143 | return {**self.custom_values, **standard_values} 144 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from queue import Queue, Empty 5 | from concurrent.futures.thread import ThreadPoolExecutor 6 | from logging import Logger 7 | from threading import Lock 8 | from typing import Dict, Union, Any, Optional, List, Callable 9 | 10 | from slack_sdk.errors import SlackApiError 11 | from slack_sdk.socket_mode.interval_runner import IntervalRunner 12 | from slack_sdk.socket_mode.listeners import ( 13 | WebSocketMessageListener, 14 | SocketModeRequestListener, 15 | ) 16 | from slack_sdk.socket_mode.request import SocketModeRequest 17 | from slack_sdk.socket_mode.response import SocketModeResponse 18 | from slack_sdk.web import WebClient 19 | 20 | 21 | class BaseSocketModeClient: 22 | logger: Logger 23 | web_client: WebClient 24 | app_token: str 25 | wss_uri: str 26 | message_queue: Queue 27 | message_listeners: List[ 28 | Union[ 29 | WebSocketMessageListener, 30 | Callable[["BaseSocketModeClient", dict, Optional[str]], None], 31 | ] 32 | ] 33 | socket_mode_request_listeners: List[ 34 | Union[ 35 | SocketModeRequestListener, 36 | Callable[["BaseSocketModeClient", SocketModeRequest], None], 37 | ] 38 | ] 39 | 40 | message_processor: IntervalRunner 41 | message_workers: ThreadPoolExecutor 42 | 43 | closed: bool 44 | connect_operation_lock: Lock 45 | 46 | def issue_new_wss_url(self) -> str: 47 | try: 48 | response = self.web_client.apps_connections_open(app_token=self.app_token) 49 | return response["url"] 50 | except SlackApiError as e: 51 | if e.response["error"] == "ratelimited": 52 | # NOTE: ratelimited errors rarely occur with this endpoint 53 | delay = int(e.response.headers.get("Retry-After", "30")) # Tier1 54 | self.logger.info(f"Rate limited. Retrying in {delay} seconds...") 55 | time.sleep(delay) 56 | # Retry to issue a new WSS URL 57 | return self.issue_new_wss_url() 58 | else: 59 | # other errors 60 | self.logger.error(f"Failed to retrieve WSS URL: {e}") 61 | raise e 62 | 63 | def is_connected(self) -> bool: 64 | return False 65 | 66 | def connect(self) -> None: 67 | raise NotImplementedError() 68 | 69 | def disconnect(self) -> None: 70 | raise NotImplementedError() 71 | 72 | def connect_to_new_endpoint(self, force: bool = False): 73 | try: 74 | self.connect_operation_lock.acquire(blocking=True, timeout=5) 75 | if force or not self.is_connected(): 76 | self.logger.info("Connecting to a new endpoint...") 77 | self.wss_uri = self.issue_new_wss_url() 78 | self.connect() 79 | self.logger.info("Connected to a new endpoint...") 80 | finally: 81 | self.connect_operation_lock.release() 82 | 83 | def close(self) -> None: 84 | self.closed = True 85 | self.disconnect() 86 | 87 | def send_message(self, message: str) -> None: 88 | raise NotImplementedError() 89 | 90 | def send_socket_mode_response( 91 | self, response: Union[Dict[str, Any], SocketModeResponse] 92 | ) -> None: 93 | if isinstance(response, SocketModeResponse): 94 | self.send_message(json.dumps(response.to_dict())) 95 | else: 96 | self.send_message(json.dumps(response)) 97 | 98 | def enqueue_message(self, message: str): 99 | self.message_queue.put(message) 100 | if self.logger.level <= logging.DEBUG: 101 | self.logger.debug( 102 | f"A new message enqueued (current queue size: {self.message_queue.qsize()})" 103 | ) 104 | 105 | def process_message(self): 106 | try: 107 | raw_message = self.message_queue.get(timeout=1) 108 | if self.logger.level <= logging.DEBUG: 109 | self.logger.debug( 110 | f"A message dequeued (current queue size: {self.message_queue.qsize()})" 111 | ) 112 | 113 | if raw_message is not None: 114 | message: dict = {} 115 | if raw_message.startswith("{"): 116 | message = json.loads(raw_message) 117 | if message.get("type") == "disconnect": 118 | self.connect_to_new_endpoint(force=True) 119 | else: 120 | 121 | def _run_message_listeners(): 122 | self.run_message_listeners(message, raw_message) 123 | 124 | self.message_workers.submit(_run_message_listeners) 125 | except Empty: 126 | pass 127 | 128 | def run_message_listeners(self, message: dict, raw_message: str) -> None: 129 | type, envelope_id = message.get("type"), message.get("envelope_id") 130 | if self.logger.level <= logging.DEBUG: 131 | self.logger.debug( 132 | f"Message processing started (type: {type}, envelope_id: {envelope_id})" 133 | ) 134 | try: 135 | # just in case, adding the same logic to reconnect here 136 | if message.get("type") == "disconnect": 137 | self.connect_to_new_endpoint(force=True) 138 | return 139 | 140 | for listener in self.message_listeners: 141 | try: 142 | listener(self, message, raw_message) 143 | except Exception as e: 144 | self.logger.exception(f"Failed to run a message listener: {e}") 145 | 146 | if len(self.socket_mode_request_listeners) > 0: 147 | request = SocketModeRequest.from_dict(message) 148 | if request is not None: 149 | for listener in self.socket_mode_request_listeners: 150 | try: 151 | listener(self, request) 152 | except Exception as e: 153 | self.logger.exception( 154 | f"Failed to run a request listener: {e}" 155 | ) 156 | except Exception as e: 157 | self.logger.exception(f"Failed to run message listeners: {e}") 158 | finally: 159 | if self.logger.level <= logging.DEBUG: 160 | self.logger.debug( 161 | f"Message processing completed (type: {type}, envelope_id: {envelope_id})" 162 | ) 163 | 164 | def process_messages(self) -> None: 165 | while not self.closed: 166 | try: 167 | self.process_message() 168 | except Exception as e: 169 | self.logger.exception(f"Failed to process a message: {e}") 170 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/socket_mode/websockets/__init__.py: -------------------------------------------------------------------------------- 1 | """websockets bassd Socket Mode client 2 | 3 | * https://api.slack.com/apis/connections/socket 4 | * https://slack.dev/python-slack-sdk/socket-mode/ 5 | * https://pypi.org/project/websockets/ 6 | 7 | """ 8 | import asyncio 9 | import logging 10 | from asyncio import Future 11 | from logging import Logger 12 | from asyncio import Queue 13 | from typing import Union, Optional, List, Callable, Awaitable 14 | 15 | import websockets 16 | from websockets.client import WebSocketClientProtocol 17 | 18 | from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient 19 | from slack_sdk.socket_mode.async_listeners import ( 20 | AsyncWebSocketMessageListener, 21 | AsyncSocketModeRequestListener, 22 | ) 23 | from slack_sdk.socket_mode.request import SocketModeRequest 24 | from slack_sdk.web.async_client import AsyncWebClient 25 | 26 | 27 | class SocketModeClient(AsyncBaseSocketModeClient): 28 | logger: Logger 29 | web_client: AsyncWebClient 30 | app_token: str 31 | wss_uri: Optional[str] 32 | auto_reconnect_enabled: bool 33 | message_queue: Queue 34 | message_listeners: List[ 35 | Union[ 36 | AsyncWebSocketMessageListener, 37 | Callable[ 38 | ["AsyncBaseSocketModeClient", dict, Optional[str]], Awaitable[None] 39 | ], 40 | ] 41 | ] 42 | socket_mode_request_listeners: List[ 43 | Union[ 44 | AsyncSocketModeRequestListener, 45 | Callable[["AsyncBaseSocketModeClient", SocketModeRequest], Awaitable[None]], 46 | ] 47 | ] 48 | 49 | message_receiver: Optional[Future] 50 | message_processor: Future 51 | 52 | ping_interval: float 53 | current_session: Optional[WebSocketClientProtocol] 54 | current_session_monitor: Optional[Future] 55 | 56 | auto_reconnect_enabled: bool 57 | default_auto_reconnect_enabled: bool 58 | closed: bool 59 | 60 | def __init__( 61 | self, 62 | app_token: str, 63 | logger: Optional[Logger] = None, 64 | web_client: Optional[AsyncWebClient] = None, 65 | auto_reconnect_enabled: bool = True, 66 | ping_interval: float = 10, 67 | ): 68 | """Socket Mode client 69 | 70 | Args: 71 | app_token: App-level token 72 | logger: Custom logger 73 | web_client: Web API client 74 | auto_reconnect_enabled: True if automatic reconnection is enabled (default: True) 75 | ping_interval: interval for ping-pong with Slack servers (seconds) 76 | """ 77 | self.app_token = app_token 78 | self.logger = logger or logging.getLogger(__name__) 79 | self.web_client = web_client or AsyncWebClient() 80 | self.closed = False 81 | self.default_auto_reconnect_enabled = auto_reconnect_enabled 82 | self.auto_reconnect_enabled = self.default_auto_reconnect_enabled 83 | self.ping_interval = ping_interval 84 | self.wss_uri = None 85 | self.message_queue = Queue() 86 | self.message_listeners = [] 87 | self.socket_mode_request_listeners = [] 88 | self.current_session = None 89 | self.current_session_monitor = None 90 | 91 | self.message_receiver = None 92 | self.message_processor = asyncio.ensure_future(self.process_messages()) 93 | 94 | async def monitor_current_session(self) -> None: 95 | while not self.closed: 96 | await asyncio.sleep(self.ping_interval) 97 | try: 98 | if self.auto_reconnect_enabled and ( 99 | self.current_session is None or self.current_session.closed 100 | ): 101 | self.logger.info( 102 | "The session seems to be already closed. Going to reconnect..." 103 | ) 104 | await self.connect_to_new_endpoint() 105 | except Exception as e: 106 | self.logger.error( 107 | "Failed to check the current session or reconnect to the server " 108 | f"(error: {type(e).__name__}, message: {e})" 109 | ) 110 | 111 | async def receive_messages(self) -> None: 112 | consecutive_error_count = 0 113 | while not self.closed: 114 | try: 115 | message = await self.current_session.recv() 116 | if message is not None: 117 | if isinstance(message, bytes): 118 | message = message.decode("utf-8") 119 | if self.logger.level <= logging.DEBUG: 120 | self.logger.debug(f"Received message: {message}") 121 | await self.enqueue_message(message) 122 | consecutive_error_count = 0 123 | except Exception as e: 124 | consecutive_error_count += 1 125 | self.logger.error( 126 | f"Failed to receive or enqueue a message: {type(e).__name__}, {e}" 127 | ) 128 | if isinstance(e, websockets.ConnectionClosedError): 129 | await asyncio.sleep(self.ping_interval) 130 | else: 131 | await asyncio.sleep(consecutive_error_count) 132 | 133 | async def connect(self): 134 | if self.wss_uri is None: 135 | self.wss_uri = await self.issue_new_wss_url() 136 | old_session: Optional[WebSocketClientProtocol] = ( 137 | None if self.current_session is None else self.current_session 138 | ) 139 | # NOTE: websockets does not support proxy settings 140 | self.current_session = await websockets.connect( 141 | uri=self.wss_uri, 142 | ping_interval=self.ping_interval, 143 | ) 144 | self.auto_reconnect_enabled = self.default_auto_reconnect_enabled 145 | self.logger.info("A new session has been established") 146 | 147 | if self.current_session_monitor is None: 148 | self.current_session_monitor = asyncio.ensure_future( 149 | self.monitor_current_session() 150 | ) 151 | 152 | if self.message_receiver is None: 153 | self.message_receiver = asyncio.ensure_future(self.receive_messages()) 154 | 155 | if old_session is not None: 156 | await old_session.close() 157 | self.logger.info("The old session has been abandoned") 158 | 159 | async def disconnect(self): 160 | if self.current_session is not None: 161 | await self.current_session.close() 162 | 163 | async def send_message(self, message: str): 164 | if self.logger.level <= logging.DEBUG: 165 | self.logger.debug(f"Sending a message: {message}") 166 | await self.current_session.send(message) 167 | 168 | async def close(self): 169 | self.closed = True 170 | self.auto_reconnect_enabled = False 171 | await self.disconnect() 172 | self.message_processor.cancel() 173 | if self.current_session_monitor is not None: 174 | self.current_session_monitor.cancel() 175 | if self.message_receiver is not None: 176 | self.message_receiver.cancel() 177 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/web/slack_response.py: -------------------------------------------------------------------------------- 1 | """A Python module for interacting and consuming responses from Slack.""" 2 | 3 | import logging 4 | from typing import Union 5 | 6 | import slack_sdk.errors as e 7 | from .internal_utils import _next_cursor_is_present 8 | 9 | 10 | class SlackResponse: 11 | """An iterable container of response data. 12 | 13 | Attributes: 14 | data (dict): The json-encoded content of the response. Along 15 | with the headers and status code information. 16 | 17 | Methods: 18 | validate: Check if the response from Slack was successful. 19 | get: Retrieves any key from the response data. 20 | next: Retrieves the next portion of results, 21 | if 'next_cursor' is present. 22 | 23 | Example: 24 | ```python 25 | import os 26 | import slack 27 | 28 | client = slack.WebClient(token=os.environ['SLACK_API_TOKEN']) 29 | 30 | response1 = client.auth_revoke(test='true') 31 | assert not response1['revoked'] 32 | 33 | response2 = client.auth_test() 34 | assert response2.get('ok', False) 35 | 36 | users = [] 37 | for page in client.users_list(limit=2): 38 | users = users + page['members'] 39 | ``` 40 | 41 | Note: 42 | Some responses return collections of information 43 | like channel and user lists. If they do it's likely 44 | that you'll only receive a portion of results. This 45 | object allows you to iterate over the response which 46 | makes subsequent API requests until your code hits 47 | 'break' or there are no more results to be found. 48 | 49 | Any attributes or methods prefixed with _underscores are 50 | intended to be "private" internal use only. They may be changed or 51 | removed at anytime. 52 | """ 53 | 54 | def __init__( 55 | self, 56 | *, 57 | client, 58 | http_verb: str, 59 | api_url: str, 60 | req_args: dict, 61 | data: Union[dict, bytes], # data can be binary data 62 | headers: dict, 63 | status_code: int, 64 | ): 65 | self.http_verb = http_verb 66 | self.api_url = api_url 67 | self.req_args = req_args 68 | self.data = data 69 | self.headers = headers 70 | self.status_code = status_code 71 | self._initial_data = data 72 | self._iteration = None # for __iter__ & __next__ 73 | self._client = client 74 | self._logger = logging.getLogger(__name__) 75 | 76 | def __str__(self): 77 | """Return the Response data if object is converted to a string.""" 78 | if isinstance(self.data, bytes): 79 | raise ValueError( 80 | "As the response.data is binary data, this operation is unsupported" 81 | ) 82 | return f"{self.data}" 83 | 84 | def __getitem__(self, key): 85 | """Retrieves any key from the data store. 86 | 87 | Note: 88 | This is implemented so users can reference the 89 | SlackResponse object like a dictionary. 90 | e.g. response["ok"] 91 | 92 | Returns: 93 | The value from data or None. 94 | """ 95 | if isinstance(self.data, bytes): 96 | raise ValueError( 97 | "As the response.data is binary data, this operation is unsupported" 98 | ) 99 | return self.data.get(key, None) 100 | 101 | def __iter__(self): 102 | """Enables the ability to iterate over the response. 103 | It's required for the iterator protocol. 104 | 105 | Note: 106 | This enables Slack cursor-based pagination. 107 | 108 | Returns: 109 | (SlackResponse) self 110 | """ 111 | self._iteration = 0 112 | self.data = self._initial_data 113 | return self 114 | 115 | def __next__(self): 116 | """Retrieves the next portion of results, if 'next_cursor' is present. 117 | 118 | Note: 119 | Some responses return collections of information 120 | like channel and user lists. If they do it's likely 121 | that you'll only receive a portion of results. This 122 | method allows you to iterate over the response until 123 | your code hits 'break' or there are no more results 124 | to be found. 125 | 126 | Returns: 127 | (SlackResponse) self 128 | With the new response data now attached to this object. 129 | 130 | Raises: 131 | SlackApiError: If the request to the Slack API failed. 132 | StopIteration: If 'next_cursor' is not present or empty. 133 | """ 134 | if isinstance(self.data, bytes): 135 | raise ValueError( 136 | "As the response.data is binary data, this operation is unsupported" 137 | ) 138 | self._iteration += 1 139 | if self._iteration == 1: 140 | return self 141 | if _next_cursor_is_present(self.data): # skipcq: PYL-R1705 142 | params = self.req_args.get("params", {}) 143 | if params is None: 144 | params = {} 145 | params.update({"cursor": self.data["response_metadata"]["next_cursor"]}) 146 | self.req_args.update({"params": params}) 147 | 148 | # This method sends a request in a synchronous way 149 | response = self._client._request_for_pagination( # skipcq: PYL-W0212 150 | api_url=self.api_url, req_args=self.req_args 151 | ) 152 | self.data = response["data"] 153 | self.headers = response["headers"] 154 | self.status_code = response["status_code"] 155 | return self.validate() 156 | else: 157 | raise StopIteration 158 | 159 | def get(self, key, default=None): 160 | """Retrieves any key from the response data. 161 | 162 | Note: 163 | This is implemented so users can reference the 164 | SlackResponse object like a dictionary. 165 | e.g. response.get("ok", False) 166 | 167 | Returns: 168 | The value from data or the specified default. 169 | """ 170 | if isinstance(self.data, bytes): 171 | raise ValueError( 172 | "As the response.data is binary data, this operation is unsupported" 173 | ) 174 | return self.data.get(key, default) 175 | 176 | def validate(self): 177 | """Check if the response from Slack was successful. 178 | 179 | Returns: 180 | (SlackResponse) 181 | This method returns it's own object. e.g. 'self' 182 | 183 | Raises: 184 | SlackApiError: The request to the Slack API failed. 185 | """ 186 | if self._logger.level <= logging.DEBUG: 187 | body = self.data if isinstance(self.data, dict) else "(binary)" 188 | self._logger.debug( 189 | "Received the following response - " 190 | f"status: {self.status_code}, " 191 | f"headers: {dict(self.headers)}, " 192 | f"body: {body}" 193 | ) 194 | if ( 195 | self.status_code == 200 196 | and self.data 197 | and (isinstance(self.data, bytes) or self.data.get("ok", False)) 198 | ): 199 | return self 200 | msg = "The request to the Slack API failed." 201 | raise e.SlackApiError(message=msg, response=self) 202 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/web/async_base_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ssl import SSLContext 3 | from typing import Optional, Union, Dict, Any 4 | 5 | import aiohttp 6 | from aiohttp import FormData, BasicAuth 7 | 8 | from .async_internal_utils import ( 9 | _files_to_data, 10 | _request_with_session, 11 | ) 12 | from .async_slack_response import AsyncSlackResponse 13 | from .deprecation import show_2020_01_deprecation 14 | from .internal_utils import ( 15 | convert_bool_to_0_or_1, 16 | _build_req_args, 17 | _get_url, 18 | get_user_agent, 19 | ) 20 | from ..proxy_env_variable_loader import load_http_proxy_from_env 21 | 22 | 23 | class AsyncBaseClient: 24 | BASE_URL = "https://www.slack.com/api/" 25 | 26 | def __init__( 27 | self, 28 | token: Optional[str] = None, 29 | base_url: str = BASE_URL, 30 | timeout: int = 30, 31 | ssl: Optional[SSLContext] = None, 32 | proxy: Optional[str] = None, 33 | session: Optional[aiohttp.ClientSession] = None, 34 | trust_env_in_session: bool = False, 35 | headers: Optional[dict] = None, 36 | user_agent_prefix: Optional[str] = None, 37 | user_agent_suffix: Optional[str] = None, 38 | # for Org-Wide App installation 39 | team_id: Optional[str] = None, 40 | logger: Optional[logging.Logger] = None, 41 | ): 42 | self.token = None if token is None else token.strip() 43 | self.base_url = base_url 44 | self.timeout = timeout 45 | self.ssl = ssl 46 | self.proxy = proxy 47 | self.session = session 48 | # https://github.com/slackapi/python-slack-sdk/issues/738 49 | self.trust_env_in_session = trust_env_in_session 50 | self.headers = headers or {} 51 | self.headers["User-Agent"] = get_user_agent( 52 | user_agent_prefix, user_agent_suffix 53 | ) 54 | self.default_params = {} 55 | if team_id is not None: 56 | self.default_params["team_id"] = team_id 57 | self._logger = logger if logger is not None else logging.getLogger(__name__) 58 | 59 | if self.proxy is None or len(self.proxy.strip()) == 0: 60 | env_variable = load_http_proxy_from_env(self._logger) 61 | if env_variable is not None: 62 | self.proxy = env_variable 63 | 64 | async def api_call( # skipcq: PYL-R1710 65 | self, 66 | api_method: str, 67 | *, 68 | http_verb: str = "POST", 69 | files: dict = None, 70 | data: Union[dict, FormData] = None, 71 | params: dict = None, 72 | json: dict = None, # skipcq: PYL-W0621 73 | headers: dict = None, 74 | auth: dict = None, 75 | ) -> AsyncSlackResponse: 76 | """Create a request and execute the API call to Slack. 77 | 78 | Args: 79 | api_method (str): The target Slack API method. 80 | e.g. 'chat.postMessage' 81 | http_verb (str): HTTP Verb. e.g. 'POST' 82 | files (dict): Files to multipart upload. 83 | e.g. {image OR file: file_object OR file_path} 84 | data: The body to attach to the request. If a dictionary is 85 | provided, form-encoding will take place. 86 | e.g. {'key1': 'value1', 'key2': 'value2'} 87 | params (dict): The URL parameters to append to the URL. 88 | e.g. {'key1': 'value1', 'key2': 'value2'} 89 | json (dict): JSON for the body to attach to the request 90 | (if files or data is not specified). 91 | e.g. {'key1': 'value1', 'key2': 'value2'} 92 | headers (dict): Additional request headers 93 | auth (dict): A dictionary that consists of client_id and client_secret 94 | 95 | Returns: 96 | (AsyncSlackResponse) 97 | The server's response to an HTTP request. Data 98 | from the response can be accessed like a dict. 99 | If the response included 'next_cursor' it can 100 | be iterated on to execute subsequent requests. 101 | 102 | Raises: 103 | SlackApiError: The following Slack API call failed: 104 | 'chat.postMessage'. 105 | SlackRequestError: Json data can only be submitted as 106 | POST requests. 107 | """ 108 | 109 | api_url = _get_url(self.base_url, api_method) 110 | if auth is not None: 111 | if isinstance(auth, dict): 112 | auth = BasicAuth(auth["client_id"], auth["client_secret"]) 113 | if isinstance(auth, BasicAuth): 114 | if headers is None: 115 | headers = {} 116 | headers["Authorization"] = auth.encode() 117 | auth = None 118 | 119 | headers = headers or {} 120 | headers.update(self.headers) 121 | req_args = _build_req_args( 122 | token=self.token, 123 | http_verb=http_verb, 124 | files=files, 125 | data=data, 126 | default_params=self.default_params, 127 | params=params, 128 | json=json, # skipcq: PYL-W0621 129 | headers=headers, 130 | auth=auth, 131 | ssl=self.ssl, 132 | proxy=self.proxy, 133 | ) 134 | 135 | show_2020_01_deprecation(api_method) 136 | 137 | return await self._send( 138 | http_verb=http_verb, 139 | api_url=api_url, 140 | req_args=req_args, 141 | ) 142 | 143 | async def _send( 144 | self, http_verb: str, api_url: str, req_args: dict 145 | ) -> AsyncSlackResponse: 146 | """Sends the request out for transmission. 147 | 148 | Args: 149 | http_verb (str): The HTTP verb. e.g. 'GET' or 'POST'. 150 | api_url (str): The Slack API url. e.g. 'https://slack.com/api/chat.postMessage' 151 | req_args (dict): The request arguments to be attached to the request. 152 | e.g. 153 | { 154 | json: { 155 | 'attachments': [{"pretext": "pre-hello", "text": "text-world"}], 156 | 'channel': '#random' 157 | } 158 | } 159 | Returns: 160 | The response parsed into a AsyncSlackResponse object. 161 | """ 162 | open_files = _files_to_data(req_args) 163 | try: 164 | if "params" in req_args: 165 | # True/False -> "1"/"0" 166 | req_args["params"] = convert_bool_to_0_or_1(req_args["params"]) 167 | 168 | res = await self._request( 169 | http_verb=http_verb, api_url=api_url, req_args=req_args 170 | ) 171 | finally: 172 | for f in open_files: 173 | f.close() 174 | 175 | data = { 176 | "client": self, 177 | "http_verb": http_verb, 178 | "api_url": api_url, 179 | "req_args": req_args, 180 | } 181 | return AsyncSlackResponse(**{**data, **res}).validate() 182 | 183 | async def _request(self, *, http_verb, api_url, req_args) -> Dict[str, Any]: 184 | """Submit the HTTP request with the running session or a new session. 185 | Returns: 186 | A dictionary of the response data. 187 | """ 188 | return await _request_with_session( 189 | current_session=self.session, 190 | timeout=self.timeout, 191 | logger=self._logger, 192 | http_verb=http_verb, 193 | api_url=api_url, 194 | req_args=req_args, 195 | ) 196 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/webhook/async_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from ssl import SSLContext 4 | from typing import Dict, Union, Optional, Any, Sequence 5 | 6 | import aiohttp 7 | from aiohttp import BasicAuth, ClientSession 8 | 9 | from slack_sdk.models.attachments import Attachment 10 | from slack_sdk.models.blocks import Block 11 | from .internal_utils import ( 12 | _debug_log_response, 13 | _build_request_headers, 14 | _build_body, 15 | get_user_agent, 16 | ) 17 | from .webhook_response import WebhookResponse 18 | from ..proxy_env_variable_loader import load_http_proxy_from_env 19 | 20 | 21 | class AsyncWebhookClient: 22 | url: str 23 | timeout: int 24 | ssl: Optional[SSLContext] 25 | proxy: Optional[str] 26 | session: Optional[ClientSession] 27 | trust_env_in_session: bool 28 | auth: Optional[BasicAuth] 29 | default_headers: Dict[str, str] 30 | logger: logging.Logger 31 | 32 | def __init__( 33 | self, 34 | url: str, 35 | timeout: int = 30, 36 | ssl: Optional[SSLContext] = None, 37 | proxy: Optional[str] = None, 38 | session: Optional[ClientSession] = None, 39 | trust_env_in_session: bool = False, 40 | auth: Optional[BasicAuth] = None, 41 | default_headers: Optional[Dict[str, str]] = None, 42 | user_agent_prefix: Optional[str] = None, 43 | user_agent_suffix: Optional[str] = None, 44 | logger: Optional[logging.Logger] = None, 45 | ): 46 | """API client for Incoming Webhooks and `response_url` 47 | 48 | https://api.slack.com/messaging/webhooks 49 | 50 | Args: 51 | url: Complete URL to send data (e.g., `https://hooks.slack.com/XXX`) 52 | timeout: Request timeout (in seconds) 53 | ssl: `ssl.SSLContext` to use for requests 54 | proxy: Proxy URL (e.g., `localhost:9000`, `http://localhost:9000`) 55 | session: `aiohttp.ClientSession` instance 56 | trust_env_in_session: True/False for `aiohttp.ClientSession` 57 | auth: Basic auth info for `aiohttp.ClientSession` 58 | default_headers: Request headers to add to all requests 59 | user_agent_prefix: Prefix for User-Agent header value 60 | user_agent_suffix: Suffix for User-Agent header value 61 | logger: Custom logger 62 | """ 63 | self.url = url 64 | self.timeout = timeout 65 | self.ssl = ssl 66 | self.proxy = proxy 67 | self.trust_env_in_session = trust_env_in_session 68 | self.session = session 69 | self.auth = auth 70 | self.default_headers = default_headers if default_headers else {} 71 | self.default_headers["User-Agent"] = get_user_agent( 72 | user_agent_prefix, user_agent_suffix 73 | ) 74 | self.logger = logger if logger is not None else logging.getLogger(__name__) 75 | 76 | if self.proxy is None or len(self.proxy.strip()) == 0: 77 | env_variable = load_http_proxy_from_env(self.logger) 78 | if env_variable is not None: 79 | self.proxy = env_variable 80 | 81 | async def send( 82 | self, 83 | *, 84 | text: Optional[str] = None, 85 | attachments: Optional[Sequence[Union[Dict[str, Any], Attachment]]] = None, 86 | blocks: Optional[Sequence[Union[Dict[str, Any], Block]]] = None, 87 | response_type: Optional[str] = None, 88 | replace_original: Optional[bool] = None, 89 | delete_original: Optional[bool] = None, 90 | headers: Optional[Dict[str, str]] = None, 91 | ) -> WebhookResponse: 92 | """Performs a Slack API request and returns the result. 93 | 94 | Args: 95 | text: The text message (even when having blocks, setting this as well is recommended as it works as fallback) 96 | attachments: A collection of attachments 97 | blocks: A collection of Block Kit UI components 98 | response_type: The type of message (either 'in_channel' or 'ephemeral') 99 | replace_original: True if you use this option for response_url requests 100 | delete_original: True if you use this option for response_url requests 101 | headers: Request headers to append only for this request 102 | 103 | Returns: 104 | Webhook response 105 | """ 106 | return await self.send_dict( 107 | # It's fine to have None value elements here 108 | # because _build_body() filters them out when constructing the actual body data 109 | body={ 110 | "text": text, 111 | "attachments": attachments, 112 | "blocks": blocks, 113 | "response_type": response_type, 114 | "replace_original": replace_original, 115 | "delete_original": delete_original, 116 | }, 117 | headers=headers, 118 | ) 119 | 120 | async def send_dict( 121 | self, body: Dict[str, Any], headers: Optional[Dict[str, str]] = None 122 | ) -> WebhookResponse: 123 | """Performs a Slack API request and returns the result. 124 | 125 | Args: 126 | body: JSON data structure (it's still a dict at this point), 127 | if you give this argument, body_params and files will be skipped 128 | headers: Request headers to append only for this request 129 | Returns: 130 | Webhook response 131 | """ 132 | return await self._perform_http_request( 133 | body=_build_body(body), 134 | headers=_build_request_headers(self.default_headers, headers), 135 | ) 136 | 137 | async def _perform_http_request( 138 | self, *, body: Dict[str, Any], headers: Dict[str, str] 139 | ) -> WebhookResponse: 140 | body = json.dumps(body) 141 | headers["Content-Type"] = "application/json;charset=utf-8" 142 | 143 | if self.logger.level <= logging.DEBUG: 144 | self.logger.debug( 145 | f"Sending a request - url: {self.url}, body: {body}, headers: {headers}" 146 | ) 147 | session: Optional[ClientSession] = None 148 | use_running_session = self.session and not self.session.closed 149 | if use_running_session: 150 | session = self.session 151 | else: 152 | session = aiohttp.ClientSession( 153 | timeout=aiohttp.ClientTimeout(total=self.timeout), 154 | auth=self.auth, 155 | trust_env=self.trust_env_in_session, 156 | ) 157 | 158 | resp: WebhookResponse 159 | try: 160 | request_kwargs = { 161 | "headers": headers, 162 | "data": body, 163 | "ssl": self.ssl, 164 | "proxy": self.proxy, 165 | } 166 | async with session.request("POST", self.url, **request_kwargs) as res: 167 | response_body: str = "" 168 | try: 169 | response_body = await res.text() 170 | except aiohttp.ContentTypeError: 171 | self.logger.debug( 172 | f"No response data returned from the following API call: {self.url}" 173 | ) 174 | 175 | resp = WebhookResponse( 176 | url=self.url, 177 | status_code=res.status, 178 | body=response_body, 179 | headers=res.headers, 180 | ) 181 | _debug_log_response(self.logger, resp) 182 | finally: 183 | if not use_running_session: 184 | await session.close() 185 | 186 | return resp 187 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/web/async_slack_response.py: -------------------------------------------------------------------------------- 1 | """A Python module for interacting and consuming responses from Slack.""" 2 | 3 | import logging 4 | from typing import Union 5 | 6 | import slack_sdk.errors as e 7 | from .internal_utils import _next_cursor_is_present 8 | 9 | 10 | class AsyncSlackResponse: 11 | """An iterable container of response data. 12 | 13 | Attributes: 14 | data (dict): The json-encoded content of the response. Along 15 | with the headers and status code information. 16 | 17 | Methods: 18 | validate: Check if the response from Slack was successful. 19 | get: Retrieves any key from the response data. 20 | next: Retrieves the next portion of results, 21 | if 'next_cursor' is present. 22 | 23 | Example: 24 | ```python 25 | import os 26 | import slack 27 | 28 | client = slack.AsyncWebClient(token=os.environ['SLACK_API_TOKEN']) 29 | 30 | response1 = await client.auth_revoke(test='true') 31 | assert not response1['revoked'] 32 | 33 | response2 = await client.auth_test() 34 | assert response2.get('ok', False) 35 | 36 | users = [] 37 | async for page in await client.users_list(limit=2): 38 | users = users + page['members'] 39 | ``` 40 | 41 | Note: 42 | Some responses return collections of information 43 | like channel and user lists. If they do it's likely 44 | that you'll only receive a portion of results. This 45 | object allows you to iterate over the response which 46 | makes subsequent API requests until your code hits 47 | 'break' or there are no more results to be found. 48 | 49 | Any attributes or methods prefixed with _underscores are 50 | intended to be "private" internal use only. They may be changed or 51 | removed at anytime. 52 | """ 53 | 54 | def __init__( 55 | self, 56 | *, 57 | client, # AsyncWebClient 58 | http_verb: str, 59 | api_url: str, 60 | req_args: dict, 61 | data: Union[dict, bytes], # data can be binary data 62 | headers: dict, 63 | status_code: int, 64 | ): 65 | self.http_verb = http_verb 66 | self.api_url = api_url 67 | self.req_args = req_args 68 | self.data = data 69 | self.headers = headers 70 | self.status_code = status_code 71 | self._initial_data = data 72 | self._iteration = None # for __iter__ & __next__ 73 | self._client = client 74 | self._logger = logging.getLogger(__name__) 75 | 76 | def __str__(self): 77 | """Return the Response data if object is converted to a string.""" 78 | if isinstance(self.data, bytes): 79 | raise ValueError( 80 | "As the response.data is binary data, this operation is unsupported" 81 | ) 82 | return f"{self.data}" 83 | 84 | def __getitem__(self, key): 85 | """Retrieves any key from the data store. 86 | 87 | Note: 88 | This is implemented so users can reference the 89 | SlackResponse object like a dictionary. 90 | e.g. response["ok"] 91 | 92 | Returns: 93 | The value from data or None. 94 | """ 95 | if isinstance(self.data, bytes): 96 | raise ValueError( 97 | "As the response.data is binary data, this operation is unsupported" 98 | ) 99 | return self.data.get(key, None) 100 | 101 | def __aiter__(self): 102 | """Enables the ability to iterate over the response. 103 | It's required async-for the iterator protocol. 104 | 105 | Note: 106 | This enables Slack cursor-based pagination. 107 | 108 | Returns: 109 | (AsyncSlackResponse) self 110 | """ 111 | if isinstance(self.data, bytes): 112 | raise ValueError( 113 | "As the response.data is binary data, this operation is unsupported" 114 | ) 115 | self._iteration = 0 116 | self.data = self._initial_data 117 | return self 118 | 119 | async def __anext__(self): 120 | """Retrieves the next portion of results, if 'next_cursor' is present. 121 | 122 | Note: 123 | Some responses return collections of information 124 | like channel and user lists. If they do it's likely 125 | that you'll only receive a portion of results. This 126 | method allows you to iterate over the response until 127 | your code hits 'break' or there are no more results 128 | to be found. 129 | 130 | Returns: 131 | (AsyncSlackResponse) self 132 | With the new response data now attached to this object. 133 | 134 | Raises: 135 | SlackApiError: If the request to the Slack API failed. 136 | StopAsyncIteration: If 'next_cursor' is not present or empty. 137 | """ 138 | if isinstance(self.data, bytes): 139 | raise ValueError( 140 | "As the response.data is binary data, this operation is unsupported" 141 | ) 142 | self._iteration += 1 143 | if self._iteration == 1: 144 | return self 145 | if _next_cursor_is_present(self.data): # skipcq: PYL-R1705 146 | params = self.req_args.get("params", {}) 147 | if params is None: 148 | params = {} 149 | params.update({"cursor": self.data["response_metadata"]["next_cursor"]}) 150 | self.req_args.update({"params": params}) 151 | 152 | response = await self._client._request( # skipcq: PYL-W0212 153 | http_verb=self.http_verb, 154 | api_url=self.api_url, 155 | req_args=self.req_args, 156 | ) 157 | 158 | self.data = response["data"] 159 | self.headers = response["headers"] 160 | self.status_code = response["status_code"] 161 | return self.validate() 162 | else: 163 | raise StopAsyncIteration 164 | 165 | def get(self, key, default=None): 166 | """Retrieves any key from the response data. 167 | 168 | Note: 169 | This is implemented so users can reference the 170 | SlackResponse object like a dictionary. 171 | e.g. response.get("ok", False) 172 | 173 | Returns: 174 | The value from data or the specified default. 175 | """ 176 | if isinstance(self.data, bytes): 177 | raise ValueError( 178 | "As the response.data is binary data, this operation is unsupported" 179 | ) 180 | return self.data.get(key, default) 181 | 182 | def validate(self): 183 | """Check if the response from Slack was successful. 184 | 185 | Returns: 186 | (AsyncSlackResponse) 187 | This method returns it's own object. e.g. 'self' 188 | 189 | Raises: 190 | SlackApiError: The request to the Slack API failed. 191 | """ 192 | if self._logger.level <= logging.DEBUG: 193 | body = self.data if isinstance(self.data, dict) else "(binary)" 194 | self._logger.debug( 195 | "Received the following response - " 196 | f"status: {self.status_code}, " 197 | f"headers: {dict(self.headers)}, " 198 | f"body: {body}" 199 | ) 200 | if ( 201 | self.status_code == 200 202 | and self.data 203 | and (isinstance(self.data, bytes) or self.data.get("ok", False)) 204 | ): 205 | return self 206 | msg = "The request to the Slack API failed." 207 | raise e.SlackApiError(message=msg, response=self) 208 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/webhook/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import urllib 4 | from http.client import HTTPResponse 5 | from ssl import SSLContext 6 | from typing import Dict, Union, Sequence, Optional 7 | from urllib.error import HTTPError 8 | from urllib.request import Request, urlopen, OpenerDirector, ProxyHandler, HTTPSHandler 9 | 10 | from slack_sdk.errors import SlackRequestError 11 | from slack_sdk.models.attachments import Attachment 12 | from slack_sdk.models.blocks import Block 13 | from .internal_utils import ( 14 | _build_body, 15 | _build_request_headers, 16 | _debug_log_response, 17 | get_user_agent, 18 | ) 19 | from .webhook_response import WebhookResponse 20 | from ..proxy_env_variable_loader import load_http_proxy_from_env 21 | 22 | 23 | class WebhookClient: 24 | url: str 25 | timeout: int 26 | ssl: Optional[SSLContext] 27 | proxy: Optional[str] 28 | default_headers: Dict[str, str] 29 | logger: logging.Logger 30 | 31 | def __init__( 32 | self, 33 | url: str, 34 | timeout: int = 30, 35 | ssl: Optional[SSLContext] = None, 36 | proxy: Optional[str] = None, 37 | default_headers: Optional[Dict[str, str]] = None, 38 | user_agent_prefix: Optional[str] = None, 39 | user_agent_suffix: Optional[str] = None, 40 | logger: Optional[logging.Logger] = None, 41 | ): 42 | """API client for Incoming Webhooks and `response_url` 43 | 44 | https://api.slack.com/messaging/webhooks 45 | 46 | Args: 47 | url: Complete URL to send data (e.g., `https://hooks.slack.com/XXX`) 48 | timeout: Request timeout (in seconds) 49 | ssl: `ssl.SSLContext` to use for requests 50 | proxy: Proxy URL (e.g., `localhost:9000`, `http://localhost:9000`) 51 | default_headers: Request headers to add to all requests 52 | user_agent_prefix: Prefix for User-Agent header value 53 | user_agent_suffix: Suffix for User-Agent header value 54 | logger: Custom logger 55 | """ 56 | self.url = url 57 | self.timeout = timeout 58 | self.ssl = ssl 59 | self.proxy = proxy 60 | self.default_headers = default_headers if default_headers else {} 61 | self.default_headers["User-Agent"] = get_user_agent( 62 | user_agent_prefix, user_agent_suffix 63 | ) 64 | self.logger = logger if logger is not None else logging.getLogger(__name__) 65 | 66 | if self.proxy is None or len(self.proxy.strip()) == 0: 67 | env_variable = load_http_proxy_from_env(self.logger) 68 | if env_variable is not None: 69 | self.proxy = env_variable 70 | 71 | def send( 72 | self, 73 | *, 74 | text: Optional[str] = None, 75 | attachments: Optional[Sequence[Union[Dict[str, any], Attachment]]] = None, 76 | blocks: Optional[Sequence[Union[Dict[str, any], Block]]] = None, 77 | response_type: Optional[str] = None, 78 | replace_original: Optional[bool] = None, 79 | delete_original: Optional[bool] = None, 80 | headers: Optional[Dict[str, str]] = None, 81 | ) -> WebhookResponse: 82 | """Performs a Slack API request and returns the result. 83 | 84 | Args: 85 | text: The text message 86 | (even when having blocks, setting this as well is recommended as it works as fallback) 87 | attachments: A collection of attachments 88 | blocks: A collection of Block Kit UI components 89 | response_type: The type of message (either 'in_channel' or 'ephemeral') 90 | replace_original: True if you use this option for response_url requests 91 | delete_original: True if you use this option for response_url requests 92 | headers: Request headers to append only for this request 93 | 94 | Returns: 95 | Webhook response 96 | """ 97 | return self.send_dict( 98 | # It's fine to have None value elements here 99 | # because _build_body() filters them out when constructing the actual body data 100 | body={ 101 | "text": text, 102 | "attachments": attachments, 103 | "blocks": blocks, 104 | "response_type": response_type, 105 | "replace_original": replace_original, 106 | "delete_original": delete_original, 107 | }, 108 | headers=headers, 109 | ) 110 | 111 | def send_dict( 112 | self, body: Dict[str, any], headers: Optional[Dict[str, str]] = None 113 | ) -> WebhookResponse: 114 | """Performs a Slack API request and returns the result. 115 | 116 | Args: 117 | body: JSON data structure (it's still a dict at this point), 118 | if you give this argument, body_params and files will be skipped 119 | headers: Request headers to append only for this request 120 | Returns: 121 | Webhook response 122 | """ 123 | return self._perform_http_request( 124 | body=_build_body(body), 125 | headers=_build_request_headers(self.default_headers, headers), 126 | ) 127 | 128 | def _perform_http_request( 129 | self, *, body: Dict[str, any], headers: Dict[str, str] 130 | ) -> WebhookResponse: 131 | body = json.dumps(body) 132 | headers["Content-Type"] = "application/json;charset=utf-8" 133 | 134 | if self.logger.level <= logging.DEBUG: 135 | self.logger.debug( 136 | f"Sending a request - url: {self.url}, body: {body}, headers: {headers}" 137 | ) 138 | try: 139 | url = self.url 140 | opener: Optional[OpenerDirector] = None 141 | # for security (BAN-B310) 142 | if url.lower().startswith("http"): 143 | req = Request( 144 | method="POST", url=url, data=body.encode("utf-8"), headers=headers 145 | ) 146 | if self.proxy is not None: 147 | if isinstance(self.proxy, str): 148 | opener = urllib.request.build_opener( 149 | ProxyHandler({"http": self.proxy, "https": self.proxy}), 150 | HTTPSHandler(context=self.ssl), 151 | ) 152 | else: 153 | raise SlackRequestError( 154 | f"Invalid proxy detected: {self.proxy} must be a str value" 155 | ) 156 | else: 157 | raise SlackRequestError(f"Invalid URL detected: {url}") 158 | 159 | # NOTE: BAN-B310 is already checked above 160 | resp: Optional[HTTPResponse] = None 161 | if opener: 162 | resp = opener.open(req, timeout=self.timeout) # skipcq: BAN-B310 163 | else: 164 | resp = urlopen( # skipcq: BAN-B310 165 | req, context=self.ssl, timeout=self.timeout 166 | ) 167 | charset: str = resp.headers.get_content_charset() or "utf-8" 168 | response_body: str = resp.read().decode(charset) 169 | resp = WebhookResponse( 170 | url=url, 171 | status_code=resp.status, 172 | body=response_body, 173 | headers=resp.headers, 174 | ) 175 | _debug_log_response(self.logger, resp) 176 | return resp 177 | 178 | except HTTPError as e: 179 | # read the response body here 180 | charset = e.headers.get_content_charset() or "utf-8" 181 | body: str = e.read().decode(charset) 182 | resp = WebhookResponse( 183 | url=url, 184 | status_code=e.code, 185 | body=body, 186 | headers=e.headers, 187 | ) 188 | if e.code == 429: 189 | # for backward-compatibility with WebClient (v.2.5.0 or older) 190 | resp.headers["Retry-After"] = resp.headers["retry-after"] 191 | _debug_log_response(self.logger, resp) 192 | return resp 193 | 194 | except Exception as err: 195 | self.logger.error(f"Failed to send a request to Slack API server: {err}") 196 | raise err 197 | -------------------------------------------------------------------------------- /lambda-code/lambda_function_updated.py: -------------------------------------------------------------------------------- 1 | import json 2 | import boto3 3 | import base64 4 | import os 5 | import urllib.request 6 | import urllib.error 7 | from botocore.exceptions import ClientError 8 | import logging 9 | 10 | # Configure logging 11 | logger = logging.getLogger() 12 | logger.setLevel(logging.INFO) 13 | 14 | def get_secret(): 15 | """Retrieve Slack webhook URL from AWS Secrets Manager""" 16 | secret_name = os.environ['SLACK_WEBHOOK_URL'] 17 | region_name = os.environ['AWS_REGION'] 18 | 19 | # Create a Secrets Manager client 20 | session = boto3.session.Session() 21 | client = session.client( 22 | service_name='secretsmanager', 23 | region_name=region_name 24 | ) 25 | 26 | try: 27 | get_secret_value_response = client.get_secret_value( 28 | SecretId=secret_name 29 | ) 30 | except ClientError as e: 31 | logger.error(f"Error retrieving secret: {e}") 32 | raise e 33 | else: 34 | # Decrypts secret using the associated KMS CMK. 35 | if 'SecretString' in get_secret_value_response: 36 | secret = get_secret_value_response['SecretString'] 37 | return secret 38 | else: 39 | decoded_binary_secret = base64.b64decode(get_secret_value_response['SecretBinary']) 40 | return decoded_binary_secret 41 | 42 | class Field: 43 | def __init__(self, type, text, emoji): 44 | self.type = type 45 | self.text = text 46 | self.emoji = emoji 47 | 48 | class Block: 49 | def __init__(self, type, **kwargs): 50 | self.type = type 51 | if kwargs.get("fields"): 52 | self.fields = kwargs.get("fields") 53 | if kwargs.get("text"): 54 | self.text = kwargs.get("text") 55 | 56 | class Text: 57 | def __init__(self, type, text, **kwargs): 58 | self.type = type 59 | self.text = text 60 | if kwargs.get("emoji"): 61 | self.emoji = kwargs.get("emoji") 62 | 63 | def get_aws_account_name(account_id): 64 | """Function to fetch account name corresponding to an account number""" 65 | logger.info(f"Fetching Account Name for accountId: {account_id}") 66 | 67 | try: 68 | client = boto3.client('organizations') 69 | response = client.describe_account(AccountId=account_id) 70 | account_name = response["Account"]["Name"] 71 | logger.info(f"Account Name retrieved: {account_name}") 72 | return account_name 73 | except ClientError as e: 74 | logger.error(f"Error fetching account name for {account_id}: {e}") 75 | return f"Account-{account_id}" # Fallback to account ID 76 | 77 | def get_application_features(): 78 | """Retrieve feature flags from AppConfig""" 79 | url = 'http://localhost:2772/applications/cost-anomaly-to-slack-application/environments/cost-anomaly-to-slack-environment/configurations/cost-anomaly-to-slack-configuration-profile' 80 | 81 | try: 82 | with urllib.request.urlopen(url, timeout=10) as response: 83 | config = response.read() 84 | return config 85 | except urllib.error.URLError as e: 86 | logger.error(f"Error retrieving AppConfig: {e}") 87 | # Return default configuration if AppConfig fails 88 | return b'{"feature-flags": {"displayAccountName": false}}' 89 | 90 | def send_slack_message(webhook_url, blocks): 91 | """Send message to Slack using webhook""" 92 | try: 93 | import urllib.request 94 | import urllib.parse 95 | 96 | payload = { 97 | 'blocks': json.dumps([ob.__dict__ for ob in blocks]) 98 | } 99 | 100 | data = urllib.parse.urlencode(payload).encode('utf-8') 101 | req = urllib.request.Request(webhook_url, data=data) 102 | req.add_header('Content-Type', 'application/x-www-form-urlencoded') 103 | 104 | with urllib.request.urlopen(req, timeout=30) as response: 105 | response_body = response.read().decode('utf-8') 106 | 107 | if response.status == 200 and response_body == "ok": 108 | logger.info("Successfully sent message to Slack") 109 | return True 110 | else: 111 | logger.error(f"Slack API error: Status {response.status}, Body: {response_body}") 112 | return False 113 | 114 | except Exception as e: 115 | logger.error(f"Error sending to Slack: {e}") 116 | return False 117 | 118 | def lambda_handler(event, context): 119 | """Main Lambda handler function""" 120 | logger.info(f"Received event: {json.dumps(event)}") 121 | 122 | try: 123 | # Retrieve Slack URL from Secrets Manager 124 | logger.info("Retrieving Slack URL from Secrets Manager") 125 | slack_url = json.loads(get_secret())["anomaly-detection-slack-webhook-url"] 126 | logger.info("Slack Webhook URL retrieved successfully") 127 | 128 | # Decode the SNS Message 129 | logger.info("Decoding SNS Message") 130 | anomaly_event = json.loads(event["Records"][0]["Sns"]["Message"]) 131 | 132 | # Extract anomaly details 133 | total_cost_impact = anomaly_event["impact"]["totalImpact"] 134 | anomaly_start_date = anomaly_event["anomalyStartDate"] 135 | anomaly_end_date = anomaly_event["anomalyEndDate"] 136 | anomaly_details_link = anomaly_event["anomalyDetailsLink"] 137 | 138 | # Build Slack message blocks 139 | blocks = [] 140 | 141 | # Header and basic information 142 | header_text = Text("plain_text", ":warning: Cost Anomaly Detected ", emoji=True) 143 | total_anomaly_cost_text = Text("mrkdwn", f"*Total Anomaly Cost*: ${total_cost_impact}") 144 | anomaly_start_date_text = Text("mrkdwn", f"*Anomaly Start Date*: {anomaly_start_date}") 145 | anomaly_end_date_text = Text("mrkdwn", f"*Anomaly End Date*: {anomaly_end_date}") 146 | anomaly_details_link_text = Text("mrkdwn", f"*Anomaly Details Link*: {anomaly_details_link}") 147 | root_causes_header_text = Text("mrkdwn", "*Root Causes* :mag:") 148 | 149 | # Add blocks 150 | blocks.append(Block("header", text=header_text.__dict__)) 151 | blocks.append(Block("section", text=total_anomaly_cost_text.__dict__)) 152 | blocks.append(Block("section", text=anomaly_start_date_text.__dict__)) 153 | blocks.append(Block("section", text=anomaly_end_date_text.__dict__)) 154 | blocks.append(Block("section", text=anomaly_details_link_text.__dict__)) 155 | blocks.append(Block("section", text=root_causes_header_text.__dict__)) 156 | 157 | # Load feature flags 158 | try: 159 | feature_list = get_application_features() 160 | feature_flag_display_account_name = json.loads(feature_list)["feature-flags"]["displayAccountName"] 161 | logger.info(f"Display account name feature flag: {feature_flag_display_account_name}") 162 | except Exception as e: 163 | logger.warning(f"Error loading feature flags, using default: {e}") 164 | feature_flag_display_account_name = False 165 | 166 | # Process root causes 167 | for root_cause in anomaly_event["rootCauses"]: 168 | fields = [] 169 | for root_cause_attribute in root_cause: 170 | if feature_flag_display_account_name and root_cause_attribute == "linkedAccount": 171 | account_name = get_aws_account_name(root_cause[root_cause_attribute]) 172 | fields.append(Field("plain_text", f"accountName: {account_name}", False)) 173 | fields.append(Field("plain_text", f"{root_cause_attribute}: {root_cause[root_cause_attribute]}", False)) 174 | blocks.append(Block("section", fields=[ob.__dict__ for ob in fields])) 175 | 176 | # Send message to Slack 177 | success = send_slack_message(slack_url, blocks) 178 | 179 | if success: 180 | return { 181 | 'statusCode': 200, 182 | 'responseMessage': 'Posted to Slack Channel Successfully' 183 | } 184 | else: 185 | return { 186 | 'statusCode': 500, 187 | 'responseMessage': 'Failed to post to Slack Channel' 188 | } 189 | 190 | except Exception as e: 191 | logger.error(f"Unexpected error in lambda_handler: {e}") 192 | return { 193 | 'statusCode': 500, 194 | 'responseMessage': f'Error processing anomaly event: {str(e)}' 195 | } 196 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/oauth/installation_store/file/__init__.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import json 3 | import logging 4 | import os 5 | from logging import Logger 6 | from pathlib import Path 7 | from typing import Optional, Union 8 | 9 | from slack_sdk.oauth.installation_store.async_installation_store import ( 10 | AsyncInstallationStore, 11 | ) 12 | from slack_sdk.oauth.installation_store.installation_store import InstallationStore 13 | from slack_sdk.oauth.installation_store.models.bot import Bot 14 | from slack_sdk.oauth.installation_store.models.installation import Installation 15 | 16 | 17 | class FileInstallationStore(InstallationStore, AsyncInstallationStore): 18 | def __init__( 19 | self, 20 | *, 21 | base_dir: str = str(Path.home()) + "/.bolt-app-installation", 22 | historical_data_enabled: bool = True, 23 | client_id: Optional[str] = None, 24 | logger: Logger = logging.getLogger(__name__), 25 | ): 26 | self.base_dir = base_dir 27 | self.historical_data_enabled = historical_data_enabled 28 | self.client_id = client_id 29 | if self.client_id is not None: 30 | self.base_dir = f"{self.base_dir}/{self.client_id}" 31 | self._logger = logger 32 | 33 | @property 34 | def logger(self) -> Logger: 35 | if self._logger is None: 36 | self._logger = logging.getLogger(__name__) 37 | return self._logger 38 | 39 | async def async_save(self, installation: Installation): 40 | return self.save(installation) 41 | 42 | def save(self, installation: Installation): 43 | none = "none" 44 | e_id = installation.enterprise_id or none 45 | t_id = installation.team_id or none 46 | team_installation_dir = f"{self.base_dir}/{e_id}-{t_id}" 47 | self._mkdir(team_installation_dir) 48 | 49 | if self.historical_data_enabled: 50 | history_version: str = str(installation.installed_at) 51 | 52 | entity: str = json.dumps(installation.to_bot().__dict__) 53 | with open(f"{team_installation_dir}/bot-latest", "w") as f: 54 | f.write(entity) 55 | with open(f"{team_installation_dir}/bot-{history_version}", "w") as f: 56 | f.write(entity) 57 | 58 | # per workspace 59 | entity: str = json.dumps(installation.__dict__) 60 | with open(f"{team_installation_dir}/installer-latest", "w") as f: 61 | f.write(entity) 62 | with open(f"{team_installation_dir}/installer-{history_version}", "w") as f: 63 | f.write(entity) 64 | 65 | # per workspace per user 66 | u_id = installation.user_id or none 67 | entity: str = json.dumps(installation.__dict__) 68 | with open(f"{team_installation_dir}/installer-{u_id}-latest", "w") as f: 69 | f.write(entity) 70 | with open( 71 | f"{team_installation_dir}/installer-{u_id}-{history_version}", "w" 72 | ) as f: 73 | f.write(entity) 74 | 75 | else: 76 | with open(f"{team_installation_dir}/bot-latest", "w") as f: 77 | entity: str = json.dumps(installation.to_bot().__dict__) 78 | f.write(entity) 79 | 80 | u_id = installation.user_id or none 81 | installer_filepath = f"{team_installation_dir}/installer-{u_id}-latest" 82 | with open(installer_filepath, "w") as f: 83 | entity: str = json.dumps(installation.__dict__) 84 | f.write(entity) 85 | 86 | async def async_find_bot( 87 | self, 88 | *, 89 | enterprise_id: Optional[str], 90 | team_id: Optional[str], 91 | is_enterprise_install: Optional[bool] = False, 92 | ) -> Optional[Bot]: 93 | return self.find_bot( 94 | enterprise_id=enterprise_id, 95 | team_id=team_id, 96 | is_enterprise_install=is_enterprise_install, 97 | ) 98 | 99 | def find_bot( 100 | self, 101 | *, 102 | enterprise_id: Optional[str], 103 | team_id: Optional[str], 104 | is_enterprise_install: Optional[bool] = False, 105 | ) -> Optional[Bot]: 106 | none = "none" 107 | e_id = enterprise_id or none 108 | t_id = team_id or none 109 | if is_enterprise_install: 110 | t_id = none 111 | bot_filepath = f"{self.base_dir}/{e_id}-{t_id}/bot-latest" 112 | try: 113 | with open(bot_filepath) as f: 114 | data = json.loads(f.read()) 115 | return Bot(**data) 116 | except FileNotFoundError as e: 117 | message = f"Failed to find bot installation data for enterprise: {e_id}, team: {t_id}: {e}" 118 | self.logger.warning(message) 119 | return None 120 | 121 | async def async_find_installation( 122 | self, 123 | *, 124 | enterprise_id: Optional[str], 125 | team_id: Optional[str], 126 | user_id: Optional[str] = None, 127 | is_enterprise_install: Optional[bool] = False, 128 | ) -> Optional[Installation]: 129 | return self.find_installation( 130 | enterprise_id=enterprise_id, 131 | team_id=team_id, 132 | user_id=user_id, 133 | is_enterprise_install=is_enterprise_install, 134 | ) 135 | 136 | def find_installation( 137 | self, 138 | *, 139 | enterprise_id: Optional[str], 140 | team_id: Optional[str], 141 | user_id: Optional[str] = None, 142 | is_enterprise_install: Optional[bool] = False, 143 | ) -> Optional[Installation]: 144 | none = "none" 145 | e_id = enterprise_id or none 146 | t_id = team_id or none 147 | if is_enterprise_install: 148 | t_id = none 149 | installation_filepath = f"{self.base_dir}/{e_id}-{t_id}/installer-latest" 150 | if user_id is not None: 151 | installation_filepath = ( 152 | f"{self.base_dir}/{e_id}-{t_id}/installer-{user_id}-latest" 153 | ) 154 | 155 | try: 156 | with open(installation_filepath) as f: 157 | data = json.loads(f.read()) 158 | return Installation(**data) 159 | except FileNotFoundError as e: 160 | message = f"Failed to find an installation data for enterprise: {e_id}, team: {t_id}: {e}" 161 | self.logger.warning(message) 162 | return None 163 | 164 | async def async_delete_bot( 165 | self, *, enterprise_id: Optional[str], team_id: Optional[str] 166 | ) -> None: 167 | return self.delete_bot(enterprise_id=enterprise_id, team_id=team_id) 168 | 169 | def delete_bot( 170 | self, *, enterprise_id: Optional[str], team_id: Optional[str] 171 | ) -> None: 172 | none = "none" 173 | e_id = enterprise_id or none 174 | t_id = team_id or none 175 | filepath_glob = f"{self.base_dir}/{e_id}-{t_id}/bot-*" 176 | self._delete_by_glob(e_id, t_id, filepath_glob) 177 | 178 | async def async_delete_installation( 179 | self, 180 | *, 181 | enterprise_id: Optional[str], 182 | team_id: Optional[str], 183 | user_id: Optional[str] = None, 184 | ) -> None: 185 | return self.delete_installation( 186 | enterprise_id=enterprise_id, team_id=team_id, user_id=user_id 187 | ) 188 | 189 | def delete_installation( 190 | self, 191 | *, 192 | enterprise_id: Optional[str], 193 | team_id: Optional[str], 194 | user_id: Optional[str] = None, 195 | ) -> None: 196 | none = "none" 197 | e_id = enterprise_id or none 198 | t_id = team_id or none 199 | if user_id is not None: 200 | filepath_glob = f"{self.base_dir}/{e_id}-{t_id}/installer-{user_id}-*" 201 | else: 202 | filepath_glob = f"{self.base_dir}/{e_id}-{t_id}/installer-*" 203 | self._delete_by_glob(e_id, t_id, filepath_glob) 204 | 205 | def _delete_by_glob(self, e_id: str, t_id: str, filepath_glob: str): 206 | for filepath in glob.glob(filepath_glob): 207 | try: 208 | os.remove(filepath) 209 | except FileNotFoundError as e: 210 | message = f"Failed to delete installation data for enterprise: {e_id}, team: {t_id}: {e}" 211 | self.logger.warning(message) 212 | 213 | @staticmethod 214 | def _mkdir(path: Union[str, Path]): 215 | if isinstance(path, str): 216 | path = Path(path) 217 | path.mkdir(parents=True, exist_ok=True) 218 | -------------------------------------------------------------------------------- /lambda-code/lambda_function.py: -------------------------------------------------------------------------------- 1 | import json 2 | from slack_sdk.webhook import WebhookClient 3 | import boto3 4 | import base64 5 | from botocore.exceptions import ClientError 6 | import os 7 | import urllib.request 8 | 9 | 10 | 11 | def get_secret(): 12 | 13 | secret_name = os.environ['SLACK_WEBHOOK_URL'] 14 | region_name = region = os.environ['AWS_REGION'] 15 | 16 | # Create a Secrets Manager client 17 | session = boto3.session.Session() 18 | client = session.client( 19 | service_name='secretsmanager', 20 | region_name=region_name 21 | ) 22 | 23 | # In this sample we only handle the specific exceptions for the 'GetSecretValue' API. 24 | # See https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html 25 | # We rethrow the exception by default. 26 | 27 | try: 28 | get_secret_value_response = client.get_secret_value( 29 | SecretId=secret_name 30 | ) 31 | except ClientError as e: 32 | if e.response['Error']['Code'] == 'DecryptionFailureException': 33 | # Secrets Manager can't decrypt the protected secret text using the provided KMS key. 34 | # Deal with the exception here, and/or rethrow at your discretion. 35 | raise e 36 | elif e.response['Error']['Code'] == 'InternalServiceErrorException': 37 | # An error occurred on the server side. 38 | # Deal with the exception here, and/or rethrow at your discretion. 39 | raise e 40 | elif e.response['Error']['Code'] == 'InvalidParameterException': 41 | # You provided an invalid value for a parameter. 42 | # Deal with the exception here, and/or rethrow at your discretion. 43 | raise e 44 | elif e.response['Error']['Code'] == 'InvalidRequestException': 45 | # You provided a parameter value that is not valid for the current state of the resource. 46 | # Deal with the exception here, and/or rethrow at your discretion. 47 | raise e 48 | elif e.response['Error']['Code'] == 'ResourceNotFoundException': 49 | # We can't find the resource that you asked for. 50 | # Deal with the exception here, and/or rethrow at your discretion. 51 | raise e 52 | else: 53 | # Decrypts secret using the associated KMS CMK. 54 | # Depending on whether the secret is a string or binary, one of these fields will be populated. 55 | if 'SecretString' in get_secret_value_response: 56 | secret = get_secret_value_response['SecretString'] 57 | return secret 58 | else: 59 | decoded_binary_secret = base64.b64decode(get_secret_value_response['SecretBinary']) 60 | 61 | class Field: 62 | def __init__(self, type, text, emoji): 63 | #type: plain_text 64 | self.type = type 65 | #text: text to be displayed 66 | self.text = text 67 | #emoji: boolean 68 | self.emoji = emoji 69 | 70 | class Block: 71 | #def __init__(self, type, text=None, fields=None): 72 | def __init__(self, type, **kwargs): 73 | #type: section 74 | self.type = type 75 | #fields: an array of fields in the section 76 | if kwargs.get("fields"): 77 | self.fields = kwargs.get("fields") 78 | if kwargs.get("text"): 79 | self.text = kwargs.get("text") 80 | 81 | class Text: 82 | #def __init__(self, type, text, emoji): 83 | def __init__(self, type, text, **kwargs): 84 | #type: plain_text 85 | self.type = type 86 | #text: text to be displayed 87 | self.text = text 88 | #emoji: boolean 89 | if kwargs.get("emoji"): 90 | self.emoji = kwargs.get("emoji") 91 | 92 | def get_aws_account_name(account_id): 93 | #Function is used to fetch account name corresponding to an account number. The account name is used to display a meaningful name in the Slack notification. For this function to operate, proper IAM permission should be granted to the Lambda function role. 94 | print("Fetching Account Name corresponding to accountId:" + account_id) 95 | 96 | #Initialise Organisations 97 | client = boto3.client('organizations') 98 | 99 | #Call describe_account in order to return the account_id corresponding to the account_number. 100 | response = client.describe_account(AccountId=account_id) 101 | 102 | accountName = response["Account"]["Name"] 103 | print("Fetching Account Name complete. Account Name:" + accountName) 104 | 105 | #Return the Account Name corresponding the Input Account ID. 106 | return response["Account"]["Name"] 107 | 108 | def get_application_features(): 109 | url = f'http://localhost:2772/applications/cost-anomaly-to-slack-application/environments/cost-anomaly-to-slack-environment/configurations/cost-anomaly-to-slack-configuration-profile' 110 | config = urllib.request.urlopen(url).read() 111 | return config 112 | 113 | def lambda_handler(event, context): 114 | 115 | print(json.dumps(event)) 116 | 117 | print("Retrieve Slack URL from Secrets Manager") 118 | 119 | slack_url = json.loads(get_secret())["anomaly-detection-slack-webhook-url"] 120 | 121 | print("Slack Webhook URL retrieved") 122 | 123 | print("Initialise Slack Webhook Client") 124 | 125 | webhook = WebhookClient(slack_url) 126 | 127 | print("Decoding the SNS Message") 128 | anomalyEvent = json.loads(event["Records"][0]["Sns"]["Message"]) 129 | 130 | #Total Cost of the Anomaly 131 | totalcostImpact = anomalyEvent["impact"]["totalImpact"] 132 | 133 | #Anomaly Detection Interval 134 | anomalyStartDate = anomalyEvent["anomalyStartDate"] 135 | anomalyEndDate = anomalyEvent["anomalyEndDate"] 136 | 137 | #anomalyDetailsLink 138 | anomalyDetailsLink = anomalyEvent["anomalyDetailsLink"] 139 | 140 | #Now, will start building the Slack Message. 141 | #Blocks is the main array thagit git holds the full message. 142 | #Instantiate an Object of the Class Block 143 | blocks = [] 144 | 145 | #MessageFormatting - Keep Appending the blocks object. Order is important here. 146 | #First, Populating the 'Text' Object that is a subset of the Block object. 147 | headerText = Text("plain_text", ":warning: Cost Anomaly Detected ", emoji = True) 148 | totalAnomalyCostText = Text("mrkdwn", "*Total Anomaly Cost*: $" + str(totalcostImpact)) 149 | rootCausesHeaderText = Text("mrkdwn", "*Root Causes* :mag:") 150 | anomalyStartDateText = Text("mrkdwn", "*Anomaly Start Date*: " + str(anomalyStartDate)) 151 | anomalyEndDateText = Text("mrkdwn", "*Anomaly End Date*: " + str(anomalyEndDate)) 152 | anomalyDetailsLinkText = Text("mrkdwn", "*Anomaly Details Link*: " + str(anomalyDetailsLink)) 153 | 154 | 155 | #Second, Start appending the 'blocks' object with the header, totalAnomalyCost and rootCausesHeaderText 156 | blocks.append(Block("header", text=headerText.__dict__)) 157 | blocks.append(Block("section", text=totalAnomalyCostText.__dict__)) 158 | blocks.append(Block("section", text=anomalyStartDateText.__dict__)) 159 | blocks.append(Block("section", text=anomalyEndDateText.__dict__)) 160 | blocks.append(Block("section", text=anomalyDetailsLinkText.__dict__)) 161 | blocks.append(Block("section", text=rootCausesHeaderText.__dict__)) 162 | 163 | #Load feature 164 | feature_list = get_application_features() 165 | feature_flag_displayAccountName = json.loads(feature_list)["feature-flags"]["displayAccountName"] 166 | print(feature_flag_displayAccountName) 167 | 168 | #Third, iterate through all possible root causes in the Anomaly Event and append the blocks as well as fields objects. 169 | for rootCause in anomalyEvent["rootCauses"]: 170 | fields = [] 171 | for rootCauseAttribute in rootCause: 172 | if feature_flag_displayAccountName == True: 173 | if rootCauseAttribute == "linkedAccount": 174 | accountName = get_aws_account_name(rootCause[rootCauseAttribute]) 175 | fields.append(Field("plain_text", "accountName" + " : " + accountName, False)) 176 | fields.append(Field("plain_text", rootCauseAttribute + " : " + rootCause[rootCauseAttribute], False)) 177 | blocks.append(Block("section", fields = [ob.__dict__ for ob in fields])) 178 | 179 | 180 | #Finally, send the message to the Slack Webhook. 181 | response = webhook.send( 182 | text= anomalyEvent, 183 | blocks= json.dumps([ob.__dict__ for ob in blocks]) 184 | 185 | ) 186 | 187 | #print(str(json.dumps(response.body))) 188 | assert response.status_code == 200 189 | assert response.body == "ok" 190 | 191 | 192 | return { 193 | 'statusCode': 200, 194 | 'responseMessage': 'Posted to Slack Channel Successfully' 195 | } 196 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/scim/v1/response.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any, List, Optional 3 | 4 | from slack_sdk.scim.v1.group import Group 5 | from slack_sdk.scim.v1.internal_utils import _to_snake_cased 6 | from slack_sdk.scim.v1.user import User 7 | 8 | 9 | class Errors: 10 | code: int 11 | description: str 12 | 13 | def __init__(self, code: int, description: str) -> None: 14 | self.code = code 15 | self.description = description 16 | 17 | def to_dict(self) -> dict: 18 | return {"code": self.code, "description": self.description} 19 | 20 | 21 | class SCIMResponse: 22 | url: str 23 | status_code: int 24 | headers: Dict[str, Any] 25 | raw_body: str 26 | body: Dict[str, Any] 27 | snake_cased_body: Dict[str, Any] 28 | 29 | errors: Optional[Errors] 30 | 31 | @property 32 | def snake_cased_body(self) -> Dict[str, Any]: 33 | if self._snake_cased_body is None: 34 | self._snake_cased_body = _to_snake_cased(self.body) 35 | return self._snake_cased_body 36 | 37 | @property 38 | def errors(self) -> Optional[Errors]: 39 | errors = self.snake_cased_body.get("errors") 40 | if errors is None: 41 | return None 42 | return Errors(**errors) 43 | 44 | def __init__( 45 | self, 46 | *, 47 | url: str, 48 | status_code: int, 49 | raw_body: str, 50 | headers: dict, 51 | ): 52 | self.url = url 53 | self.status_code = status_code 54 | self.headers = headers 55 | self.raw_body = raw_body 56 | self.body = ( 57 | json.loads(raw_body) 58 | if raw_body is not None and raw_body.startswith("{") 59 | else None 60 | ) 61 | self._snake_cased_body = None 62 | 63 | def __repr__(self): 64 | dict_value = {} 65 | for key, value in vars(self).items(): 66 | dict_value[key] = value.to_dict() if hasattr(value, "to_dict") else value 67 | 68 | if dict_value: # skipcq: PYL-R1705 69 | return f"" 70 | else: 71 | return self.__str__() 72 | 73 | 74 | # --------------------------------- 75 | # Users 76 | # --------------------------------- 77 | 78 | 79 | class SearchUsersResponse(SCIMResponse): 80 | users: List[User] 81 | 82 | @property 83 | def users(self) -> List[User]: 84 | return [User(**r) for r in self.snake_cased_body.get("resources")] 85 | 86 | def __init__(self, underlying: SCIMResponse): 87 | self.underlying = underlying 88 | self.url = underlying.url 89 | self.status_code = underlying.status_code 90 | self.headers = underlying.headers 91 | self.raw_body = underlying.raw_body 92 | self.body = underlying.body 93 | self._snake_cased_body = None 94 | 95 | 96 | class ReadUserResponse(SCIMResponse): 97 | user: User 98 | 99 | @property 100 | def user(self) -> User: 101 | return User(**self.snake_cased_body) 102 | 103 | def __init__(self, underlying: SCIMResponse): 104 | self.underlying = underlying 105 | self.url = underlying.url 106 | self.status_code = underlying.status_code 107 | self.headers = underlying.headers 108 | self.raw_body = underlying.raw_body 109 | self.body = underlying.body 110 | self._snake_cased_body = None 111 | 112 | 113 | class UserCreateResponse(SCIMResponse): 114 | user: User 115 | 116 | @property 117 | def user(self) -> User: 118 | return User(**self.snake_cased_body) 119 | 120 | def __init__(self, underlying: SCIMResponse): 121 | self.underlying = underlying 122 | self.url = underlying.url 123 | self.status_code = underlying.status_code 124 | self.headers = underlying.headers 125 | self.raw_body = underlying.raw_body 126 | self.body = underlying.body 127 | self._snake_cased_body = None 128 | 129 | 130 | class UserPatchResponse(SCIMResponse): 131 | user: User 132 | 133 | @property 134 | def user(self) -> User: 135 | return User(**self.snake_cased_body) 136 | 137 | def __init__(self, underlying: SCIMResponse): 138 | self.underlying = underlying 139 | self.url = underlying.url 140 | self.status_code = underlying.status_code 141 | self.headers = underlying.headers 142 | self.raw_body = underlying.raw_body 143 | self.body = underlying.body 144 | self._snake_cased_body = None 145 | 146 | 147 | class UserUpdateResponse(SCIMResponse): 148 | user: User 149 | 150 | @property 151 | def user(self) -> User: 152 | return User(**self.snake_cased_body) 153 | 154 | def __init__(self, underlying: SCIMResponse): 155 | self.underlying = underlying 156 | self.url = underlying.url 157 | self.status_code = underlying.status_code 158 | self.headers = underlying.headers 159 | self.raw_body = underlying.raw_body 160 | self.body = underlying.body 161 | self._snake_cased_body = None 162 | 163 | 164 | class UserDeleteResponse(SCIMResponse): 165 | def __init__(self, underlying: SCIMResponse): 166 | self.underlying = underlying 167 | self.url = underlying.url 168 | self.status_code = underlying.status_code 169 | self.headers = underlying.headers 170 | self.raw_body = underlying.raw_body 171 | self.body = underlying.body 172 | self._snake_cased_body = None 173 | 174 | 175 | # --------------------------------- 176 | # Groups 177 | # --------------------------------- 178 | 179 | 180 | class SearchGroupsResponse(SCIMResponse): 181 | groups: List[Group] 182 | 183 | @property 184 | def groups(self) -> List[Group]: 185 | return [Group(**r) for r in self.snake_cased_body.get("resources")] 186 | 187 | def __init__(self, underlying: SCIMResponse): 188 | self.underlying = underlying 189 | self.url = underlying.url 190 | self.status_code = underlying.status_code 191 | self.headers = underlying.headers 192 | self.raw_body = underlying.raw_body 193 | self.body = underlying.body 194 | self._snake_cased_body = None 195 | 196 | 197 | class ReadGroupResponse(SCIMResponse): 198 | group: Group 199 | 200 | @property 201 | def group(self) -> Group: 202 | return Group(**self.snake_cased_body) 203 | 204 | def __init__(self, underlying: SCIMResponse): 205 | self.underlying = underlying 206 | self.url = underlying.url 207 | self.status_code = underlying.status_code 208 | self.headers = underlying.headers 209 | self.raw_body = underlying.raw_body 210 | self.body = underlying.body 211 | self._snake_cased_body = None 212 | 213 | 214 | class GroupCreateResponse(SCIMResponse): 215 | group: Group 216 | 217 | @property 218 | def group(self) -> Group: 219 | return Group(**self.snake_cased_body) 220 | 221 | def __init__(self, underlying: SCIMResponse): 222 | self.underlying = underlying 223 | self.url = underlying.url 224 | self.status_code = underlying.status_code 225 | self.headers = underlying.headers 226 | self.raw_body = underlying.raw_body 227 | self.body = underlying.body 228 | self._snake_cased_body = None 229 | 230 | 231 | class GroupPatchResponse(SCIMResponse): 232 | def __init__(self, underlying: SCIMResponse): 233 | self.underlying = underlying 234 | self.url = underlying.url 235 | self.status_code = underlying.status_code 236 | self.headers = underlying.headers 237 | self.raw_body = underlying.raw_body 238 | self.body = underlying.body 239 | self._snake_cased_body = None 240 | 241 | 242 | class GroupUpdateResponse(SCIMResponse): 243 | group: Group 244 | 245 | @property 246 | def group(self) -> Group: 247 | return Group(**self.snake_cased_body) 248 | 249 | def __init__(self, underlying: SCIMResponse): 250 | self.underlying = underlying 251 | self.url = underlying.url 252 | self.status_code = underlying.status_code 253 | self.headers = underlying.headers 254 | self.raw_body = underlying.raw_body 255 | self.body = underlying.body 256 | self._snake_cased_body = None 257 | 258 | 259 | class GroupDeleteResponse(SCIMResponse): 260 | def __init__(self, underlying: SCIMResponse): 261 | self.underlying = underlying 262 | self.url = underlying.url 263 | self.status_code = underlying.status_code 264 | self.headers = underlying.headers 265 | self.raw_body = underlying.raw_body 266 | self.body = underlying.body 267 | self._snake_cased_body = None 268 | -------------------------------------------------------------------------------- /lambda-code/slack_sdk/web/legacy_slack_response.py: -------------------------------------------------------------------------------- 1 | """A Python module for interacting and consuming responses from Slack.""" 2 | 3 | import asyncio 4 | 5 | # Standard Imports 6 | import logging 7 | 8 | # Internal Imports 9 | from typing import Union 10 | 11 | import slack_sdk.errors as e 12 | 13 | 14 | class LegacySlackResponse(object): # skipcq: PYL-R0205 15 | """An iterable container of response data. 16 | 17 | Attributes: 18 | data (dict): The json-encoded content of the response. Along 19 | with the headers and status code information. 20 | 21 | Methods: 22 | validate: Check if the response from Slack was successful. 23 | get: Retrieves any key from the response data. 24 | next: Retrieves the next portion of results, 25 | if 'next_cursor' is present. 26 | 27 | Example: 28 | ```python 29 | import os 30 | import slack 31 | 32 | client = slack.WebClient(token=os.environ['SLACK_API_TOKEN']) 33 | 34 | response1 = client.auth_revoke(test='true') 35 | assert not response1['revoked'] 36 | 37 | response2 = client.auth_test() 38 | assert response2.get('ok', False) 39 | 40 | users = [] 41 | for page in client.users_list(limit=2): 42 | TODO: This example should specify when to break. 43 | users = users + page['members'] 44 | ``` 45 | 46 | Note: 47 | Some responses return collections of information 48 | like channel and user lists. If they do it's likely 49 | that you'll only receive a portion of results. This 50 | object allows you to iterate over the response which 51 | makes subsequent API requests until your code hits 52 | 'break' or there are no more results to be found. 53 | 54 | Any attributes or methods prefixed with _underscores are 55 | intended to be "private" internal use only. They may be changed or 56 | removed at anytime. 57 | """ 58 | 59 | def __init__( 60 | self, 61 | *, 62 | client, 63 | http_verb: str, 64 | api_url: str, 65 | req_args: dict, 66 | data: Union[dict, bytes], # data can be binary data 67 | headers: dict, 68 | status_code: int, 69 | use_sync_aiohttp: bool = True, # True for backward-compatibility 70 | ): 71 | self.http_verb = http_verb 72 | self.api_url = api_url 73 | self.req_args = req_args 74 | self.data = data 75 | self.headers = headers 76 | self.status_code = status_code 77 | self._initial_data = data 78 | self._client = client # LegacyWebClient 79 | self._use_sync_aiohttp = use_sync_aiohttp 80 | self._logger = logging.getLogger(__name__) 81 | 82 | def __str__(self): 83 | """Return the Response data if object is converted to a string.""" 84 | if isinstance(self.data, bytes): 85 | raise ValueError( 86 | "As the response.data is binary data, this operation is unsupported" 87 | ) 88 | return f"{self.data}" 89 | 90 | def __getitem__(self, key): 91 | """Retrieves any key from the data store. 92 | 93 | Note: 94 | This is implemented so users can reference the 95 | SlackResponse object like a dictionary. 96 | e.g. response["ok"] 97 | 98 | Returns: 99 | The value from data or None. 100 | """ 101 | if isinstance(self.data, bytes): 102 | raise ValueError( 103 | "As the response.data is binary data, this operation is unsupported" 104 | ) 105 | return self.data.get(key, None) 106 | 107 | def __iter__(self): 108 | """Enables the ability to iterate over the response. 109 | It's required for the iterator protocol. 110 | 111 | Note: 112 | This enables Slack cursor-based pagination. 113 | 114 | Returns: 115 | (SlackResponse) self 116 | """ 117 | if isinstance(self.data, bytes): 118 | raise ValueError( 119 | "As the response.data is binary data, this operation is unsupported" 120 | ) 121 | self._iteration = 0 # skipcq: PYL-W0201 122 | self.data = self._initial_data 123 | return self 124 | 125 | def __next__(self): 126 | """Retrieves the next portion of results, if 'next_cursor' is present. 127 | 128 | Note: 129 | Some responses return collections of information 130 | like channel and user lists. If they do it's likely 131 | that you'll only receive a portion of results. This 132 | method allows you to iterate over the response until 133 | your code hits 'break' or there are no more results 134 | to be found. 135 | 136 | Returns: 137 | (SlackResponse) self 138 | With the new response data now attached to this object. 139 | 140 | Raises: 141 | SlackApiError: If the request to the Slack API failed. 142 | StopIteration: If 'next_cursor' is not present or empty. 143 | """ 144 | if isinstance(self.data, bytes): 145 | raise ValueError( 146 | "As the response.data is binary data, this operation is unsupported" 147 | ) 148 | self._iteration += 1 149 | if self._iteration == 1: 150 | return self 151 | if self._next_cursor_is_present(self.data): # skipcq: PYL-R1705 152 | params = self.req_args.get("params", {}) 153 | if params is None: 154 | params = {} 155 | params.update({"cursor": self.data["response_metadata"]["next_cursor"]}) 156 | self.req_args.update({"params": params}) 157 | 158 | if self._use_sync_aiohttp: 159 | # We no longer recommend going with this way 160 | response = asyncio.get_event_loop().run_until_complete( 161 | self._client._request( # skipcq: PYL-W0212 162 | http_verb=self.http_verb, 163 | api_url=self.api_url, 164 | req_args=self.req_args, 165 | ) 166 | ) 167 | else: 168 | # This method sends a request in a synchronous way 169 | response = self._client._request_for_pagination( # skipcq: PYL-W0212 170 | api_url=self.api_url, req_args=self.req_args 171 | ) 172 | 173 | self.data = response["data"] 174 | self.headers = response["headers"] 175 | self.status_code = response["status_code"] 176 | return self.validate() 177 | else: 178 | raise StopIteration 179 | 180 | def get(self, key, default=None): 181 | """Retrieves any key from the response data. 182 | 183 | Note: 184 | This is implemented so users can reference the 185 | SlackResponse object like a dictionary. 186 | e.g. response.get("ok", False) 187 | 188 | Returns: 189 | The value from data or the specified default. 190 | """ 191 | if isinstance(self.data, bytes): 192 | raise ValueError( 193 | "As the response.data is binary data, this operation is unsupported" 194 | ) 195 | return self.data.get(key, default) 196 | 197 | def validate(self): 198 | """Check if the response from Slack was successful. 199 | 200 | Returns: 201 | (SlackResponse) 202 | This method returns it's own object. e.g. 'self' 203 | 204 | Raises: 205 | SlackApiError: The request to the Slack API failed. 206 | """ 207 | if self._logger.level <= logging.DEBUG: 208 | body = self.data if isinstance(self.data, dict) else "(binary)" 209 | self._logger.debug( 210 | "Received the following response - " 211 | f"status: {self.status_code}, " 212 | f"headers: {dict(self.headers)}, " 213 | f"body: {body}" 214 | ) 215 | if ( 216 | self.status_code == 200 217 | and self.data 218 | and (isinstance(self.data, bytes) or self.data.get("ok", False)) 219 | ): 220 | return self 221 | msg = "The request to the Slack API failed." 222 | raise e.SlackApiError(message=msg, response=self) 223 | 224 | @staticmethod 225 | def _next_cursor_is_present(data): 226 | """Determine if the response contains 'next_cursor' 227 | and 'next_cursor' is not empty. 228 | 229 | Returns: 230 | A boolean value. 231 | """ 232 | present = ( 233 | "response_metadata" in data 234 | and "next_cursor" in data["response_metadata"] 235 | and data["response_metadata"]["next_cursor"] != "" 236 | ) 237 | return present 238 | --------------------------------------------------------------------------------