├── 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 | 
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 | 
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 |
--------------------------------------------------------------------------------