|
21 | 21 | import warnings |
22 | 22 | from functools import cached_property |
23 | 23 | from pathlib import Path |
24 | | -from typing import TYPE_CHECKING, Any, Sequence |
| 24 | +from typing import TYPE_CHECKING, Any, Sequence, TypedDict |
25 | 25 |
|
26 | 26 | from slack_sdk import WebClient |
27 | 27 | from slack_sdk.errors import SlackApiError |
| 28 | +from typing_extensions import NotRequired |
28 | 29 |
|
29 | 30 | from airflow.exceptions import AirflowNotFoundException |
30 | 31 | from airflow.hooks.base import BaseHook |
|
36 | 37 | from slack_sdk.web.slack_response import SlackResponse |
37 | 38 |
|
38 | 39 |
|
| 40 | +class FileUploadTypeDef(TypedDict): |
| 41 | + """ |
| 42 | + Represents the structure of the file upload data. |
| 43 | +
|
| 44 | + :ivar file: Optional. Path to file which need to be sent. |
| 45 | + :ivar content: Optional. File contents. If omitting this parameter, you must provide a file. |
| 46 | + :ivar filename: Optional. Displayed filename. |
| 47 | + :ivar title: Optional. The title of the uploaded file. |
| 48 | + :ivar alt_txt: Optional. Description of image for screen-reader. |
| 49 | + :ivar snippet_type: Optional. Syntax type of the snippet being uploaded. |
| 50 | + """ |
| 51 | + |
| 52 | + file: NotRequired[str | None] |
| 53 | + content: NotRequired[str | None] |
| 54 | + filename: NotRequired[str | None] |
| 55 | + title: NotRequired[str | None] |
| 56 | + alt_txt: NotRequired[str | None] |
| 57 | + snippet_type: NotRequired[str | None] |
| 58 | + |
| 59 | + |
39 | 60 | class SlackHook(BaseHook): |
40 | 61 | """ |
41 | 62 | Creates a Slack API Connection to be used for calls. |
@@ -111,6 +132,9 @@ def __init__( |
111 | 132 | extra_client_args["logger"] = self.log |
112 | 133 | self.extra_client_args = extra_client_args |
113 | 134 |
|
| 135 | + # Use for caching channels result |
| 136 | + self._channels_mapping: dict[str, str] = {} |
| 137 | + |
114 | 138 | @cached_property |
115 | 139 | def client(self) -> WebClient: |
116 | 140 | """Get the underlying slack_sdk.WebClient (cached).""" |
@@ -212,6 +236,128 @@ def send_file( |
212 | 236 | channels=channels, |
213 | 237 | ) |
214 | 238 |
|
| 239 | + def send_file_v2( |
| 240 | + self, |
| 241 | + *, |
| 242 | + channel_id: str | None = None, |
| 243 | + file_uploads: FileUploadTypeDef | list[FileUploadTypeDef], |
| 244 | + initial_comment: str | None = None, |
| 245 | + ) -> SlackResponse: |
| 246 | + """ |
| 247 | + Sends one or more files to a Slack channel using the Slack SDK Client method `files_upload_v2`. |
| 248 | +
|
| 249 | + :param channel_id: The ID of the channel to send the file to. |
| 250 | + If omitting this parameter, then file will send to workspace. |
| 251 | + :param file_uploads: The file(s) specification to upload. |
| 252 | + :param initial_comment: The message text introducing the file in specified ``channel``. |
| 253 | + """ |
| 254 | + if channel_id and channel_id.startswith("#"): |
| 255 | + retried_channel_id = self.get_channel_id(channel_id[1:]) |
| 256 | + warnings.warn( |
| 257 | + "The method `files_upload_v2` in the Slack SDK Client expects a Slack Channel ID, " |
| 258 | + f"but received a Slack Channel Name. To resolve this, consider replacing {channel_id!r} " |
| 259 | + f"with the corresponding Channel ID {retried_channel_id!r}.", |
| 260 | + UserWarning, |
| 261 | + stacklevel=2, |
| 262 | + ) |
| 263 | + channel_id = retried_channel_id |
| 264 | + |
| 265 | + if not isinstance(file_uploads, list): |
| 266 | + file_uploads = [file_uploads] |
| 267 | + for file_upload in file_uploads: |
| 268 | + if not file_upload.get("filename"): |
| 269 | + # Some of early version of Slack SDK (such as 3.19.0) raise an error if ``filename`` not set. |
| 270 | + file_upload["filename"] = "Uploaded file" |
| 271 | + |
| 272 | + return self.client.files_upload_v2( |
| 273 | + channel=channel_id, |
| 274 | + # mypy doesn't happy even if TypedDict used instead of dict[str, Any] |
| 275 | + # see: https://github.com/python/mypy/issues/4976 |
| 276 | + file_uploads=file_uploads, # type: ignore[arg-type] |
| 277 | + initial_comment=initial_comment, |
| 278 | + ) |
| 279 | + |
| 280 | + def send_file_v1_to_v2( |
| 281 | + self, |
| 282 | + *, |
| 283 | + channels: str | Sequence[str] | None = None, |
| 284 | + file: str | Path | None = None, |
| 285 | + content: str | None = None, |
| 286 | + filename: str | None = None, |
| 287 | + initial_comment: str | None = None, |
| 288 | + title: str | None = None, |
| 289 | + filetype: str | None = None, |
| 290 | + ) -> list[SlackResponse]: |
| 291 | + """ |
| 292 | + Smooth transition between ``send_file`` and ``send_file_v2`` methods. |
| 293 | +
|
| 294 | + :param channels: Comma-separated list of channel names or IDs where the file will be shared. |
| 295 | + If omitting this parameter, then file will send to workspace. |
| 296 | + File would be uploaded for each channel individually. |
| 297 | + :param file: Path to file which need to be sent. |
| 298 | + :param content: File contents. If omitting this parameter, you must provide a file. |
| 299 | + :param filename: Displayed filename. |
| 300 | + :param initial_comment: The message text introducing the file in specified ``channels``. |
| 301 | + :param title: Title of the file. |
| 302 | + :param filetype: A file type identifier. |
| 303 | + """ |
| 304 | + if not exactly_one(file, content): |
| 305 | + raise ValueError("Either `file` or `content` must be provided, not both.") |
| 306 | + if file: |
| 307 | + file = Path(file) |
| 308 | + file_uploads: FileUploadTypeDef = {"file": file.__fspath__(), "filename": filename or file.name} |
| 309 | + else: |
| 310 | + file_uploads = {"content": content, "filename": filename} |
| 311 | + |
| 312 | + file_uploads.update({"title": title, "snippet_type": filetype}) |
| 313 | + |
| 314 | + if channels: |
| 315 | + if isinstance(channels, str): |
| 316 | + channels = channels.split(",") |
| 317 | + channels_to_share: list[str | None] = list(map(str.strip, channels)) |
| 318 | + else: |
| 319 | + channels_to_share = [None] |
| 320 | + |
| 321 | + responses = [] |
| 322 | + for channel in channels_to_share: |
| 323 | + responses.append( |
| 324 | + self.send_file_v2( |
| 325 | + channel_id=channel, file_uploads=file_uploads, initial_comment=initial_comment |
| 326 | + ) |
| 327 | + ) |
| 328 | + return responses |
| 329 | + |
| 330 | + def get_channel_id(self, channel_name: str) -> str: |
| 331 | + """ |
| 332 | + Retrieves a Slack channel id by a channel name. |
| 333 | +
|
| 334 | + It continuously iterates over all Slack channels (public and private) |
| 335 | + until it finds the desired channel name in addition cache results for further usage. |
| 336 | +
|
| 337 | + .. seealso:: |
| 338 | + https://api.slack.com/methods/conversations.list |
| 339 | +
|
| 340 | + :param channel_name: The name of the Slack channel for which ID has to be found. |
| 341 | + """ |
| 342 | + next_cursor = None |
| 343 | + while not (channel_id := self._channels_mapping.get(channel_name)): |
| 344 | + res = self.client.conversations_list(cursor=next_cursor, types="public_channel,private_channel") |
| 345 | + if TYPE_CHECKING: |
| 346 | + # Slack SDK response type too broad, this should make mypy happy |
| 347 | + assert isinstance(res.data, dict) |
| 348 | + |
| 349 | + for channel_data in res.data.get("channels", []): |
| 350 | + self._channels_mapping[channel_data["name"]] = channel_data["id"] |
| 351 | + |
| 352 | + if not (next_cursor := res.data.get("response_metadata", {}).get("next_cursor")): |
| 353 | + channel_id = self._channels_mapping.get(channel_name) |
| 354 | + break |
| 355 | + |
| 356 | + if not channel_id: |
| 357 | + msg = f"Unable to find slack channel with name: {channel_name!r}" |
| 358 | + raise LookupError(msg) |
| 359 | + return channel_id |
| 360 | + |
215 | 361 | def test_connection(self): |
216 | 362 | """Tests the Slack API connection. |
217 | 363 |
|
|
0 commit comments