nui-python-shared-utils 2.0__py3-none-any.whl → 2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/METADATA +7 -5
- {nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/RECORD +11 -11
- {nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/WHEEL +1 -1
- nui_shared_utils/__init__.py +2 -1
- nui_shared_utils/es_client.py +22 -8
- nui_shared_utils/slack_client.py +471 -121
- nui_shared_utils/slack_formatter.py +10 -11
- nui_shared_utils/timezone.py +13 -9
- {nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/entry_points.txt +0 -0
- {nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/licenses/LICENSE +0 -0
- {nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nui-python-shared-utils
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2
|
|
4
4
|
Summary: Shared Python utilities for AWS Lambda, CLI tools, and agents with Slack, Elasticsearch, and monitoring integrations
|
|
5
5
|
Home-page: https://github.com/nuimarkets/nui-python-shared-utils
|
|
6
6
|
Author: NUI Markets
|
|
@@ -49,11 +49,11 @@ Requires-Python: >=3.9
|
|
|
49
49
|
Description-Content-Type: text/markdown
|
|
50
50
|
License-File: LICENSE
|
|
51
51
|
Requires-Dist: boto3>=1.20.0
|
|
52
|
-
Requires-Dist: pytz>=2021.3
|
|
53
52
|
Requires-Dist: click>=8.0.0
|
|
54
53
|
Requires-Dist: pyyaml>=6.0
|
|
54
|
+
Requires-Dist: tzdata>=2023.3; platform_system == "Windows"
|
|
55
55
|
Provides-Extra: elasticsearch
|
|
56
|
-
Requires-Dist: elasticsearch<
|
|
56
|
+
Requires-Dist: elasticsearch<10.0.0,>=7.17.0; extra == "elasticsearch"
|
|
57
57
|
Provides-Extra: database
|
|
58
58
|
Requires-Dist: pymysql>=1.0.0; extra == "database"
|
|
59
59
|
Requires-Dist: psycopg2-binary>=2.9.0; extra == "database"
|
|
@@ -64,12 +64,14 @@ Requires-Dist: aws-lambda-powertools<4.0.0,>=3.6.0; extra == "powertools"
|
|
|
64
64
|
Requires-Dist: coloredlogs>=15.0; extra == "powertools"
|
|
65
65
|
Provides-Extra: jwt
|
|
66
66
|
Requires-Dist: rsa>=4.9; extra == "jwt"
|
|
67
|
+
Provides-Extra: timezone
|
|
68
|
+
Requires-Dist: tzdata>=2023.3; extra == "timezone"
|
|
67
69
|
Provides-Extra: snowflake
|
|
68
70
|
Requires-Dist: snowflake-sql-api<0.2.0,>=0.1.1; extra == "snowflake"
|
|
69
71
|
Provides-Extra: llm
|
|
70
72
|
Requires-Dist: anthropic[bedrock]<1.0.0,>=0.45.0; extra == "llm"
|
|
71
73
|
Provides-Extra: all
|
|
72
|
-
Requires-Dist: elasticsearch<
|
|
74
|
+
Requires-Dist: elasticsearch<10.0.0,>=7.17.0; extra == "all"
|
|
73
75
|
Requires-Dist: pymysql>=1.0.0; extra == "all"
|
|
74
76
|
Requires-Dist: psycopg2-binary>=2.9.0; extra == "all"
|
|
75
77
|
Requires-Dist: slack-sdk>=3.19.0; extra == "all"
|
|
@@ -85,11 +87,11 @@ Requires-Dist: black>=22.0.0; extra == "dev"
|
|
|
85
87
|
Requires-Dist: mypy>=0.990; extra == "dev"
|
|
86
88
|
Requires-Dist: boto3-stubs[essential]>=1.20.0; extra == "dev"
|
|
87
89
|
Requires-Dist: types-PyYAML>=6.0.0; extra == "dev"
|
|
88
|
-
Requires-Dist: types-pytz>=2021.3.0; extra == "dev"
|
|
89
90
|
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
90
91
|
Requires-Dist: build>=0.8.0; extra == "dev"
|
|
91
92
|
Requires-Dist: rsa>=4.9; extra == "dev"
|
|
92
93
|
Requires-Dist: cryptography>=41.0.0; extra == "dev"
|
|
94
|
+
Requires-Dist: tzdata>=2023.3; extra == "dev"
|
|
93
95
|
Requires-Dist: snowflake-sql-api<0.2.0,>=0.1.1; extra == "dev"
|
|
94
96
|
Requires-Dist: anthropic[bedrock]<1.0.0,>=0.45.0; extra == "dev"
|
|
95
97
|
Dynamic: author
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
nui_python_shared_utils-2.
|
|
2
|
-
nui_shared_utils/__init__.py,sha256=
|
|
1
|
+
nui_python_shared_utils-2.2.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
|
|
2
|
+
nui_shared_utils/__init__.py,sha256=wbm10x7THNbrCBGClWiWSVkTNxcBKcHZ-HjYpRQBFrk,13354
|
|
3
3
|
nui_shared_utils/base_client.py,sha256=I1lKQGhrKyvujV2zps0TrtEdPDqMCwRQaIh6ceGONXQ,11016
|
|
4
4
|
nui_shared_utils/cli.py,sha256=JJpSoQWKvAz4b8cO30yFNi5vY9jmqrCHzbFpvrVTkbU,8747
|
|
5
5
|
nui_shared_utils/cloudwatch_metrics.py,sha256=p0J-s63UJDGK14TRGt0k8yoj7aWSSM55UKdgcuaT1dU,12098
|
|
6
6
|
nui_shared_utils/config.py,sha256=ZbkUKDxnHMJiHNv-TlWKjotmf2mH1Vz3GaIXgGfWg7E,4524
|
|
7
7
|
nui_shared_utils/db_client.py,sha256=HyVKJ1Su0dVPV1QI2MU5UtT8myDRCjKgV60xRL4SEdE,21726
|
|
8
8
|
nui_shared_utils/error_handler.py,sha256=pJ6b_mI0Ait8QRbs9UmLzYUN-Ft0l_dnpudVO_6LRSE,12171
|
|
9
|
-
nui_shared_utils/es_client.py,sha256=
|
|
9
|
+
nui_shared_utils/es_client.py,sha256=3MlrbddTrRN2mEH1b3TNzcmwYycLdujoIq_5_j-Cyfc,16714
|
|
10
10
|
nui_shared_utils/es_query_builder.py,sha256=UuheQf8b2UXaOiJlYCKYIfKfVERDZ3ag1P0OOOWm9do,12158
|
|
11
11
|
nui_shared_utils/jwt_auth.py,sha256=2Ag1zZKxd2R8QBz3aQA4h8OtKHQvUVKrjUp6lXpJxJU,9143
|
|
12
12
|
nui_shared_utils/lambda_helpers.py,sha256=psHVotpmOfnmyQCoOt4MSIEj7VwWcy6gZM3vYSZwjOk,3041
|
|
@@ -14,10 +14,10 @@ nui_shared_utils/llm.py,sha256=adNUO8Qbeb3__GvpROxWSUERkpiazKiGJFE5xzU5eZ0,8351
|
|
|
14
14
|
nui_shared_utils/log_processors.py,sha256=x5gz1LEkbmoMCZ3ZB6q-mbn26dSzwh1trz4wSkBGxqk,5538
|
|
15
15
|
nui_shared_utils/powertools_helpers.py,sha256=qc-lYLfY-QX20gALfPOJcihrtJhiCU-fJW8rsPT7LJE,11037
|
|
16
16
|
nui_shared_utils/secrets_helper.py,sha256=_jQkMWzjayZ8vvyHG2yuuxur-SQYUUONfcsZ0MF8qOw,7025
|
|
17
|
-
nui_shared_utils/slack_client.py,sha256=
|
|
18
|
-
nui_shared_utils/slack_formatter.py,sha256=
|
|
17
|
+
nui_shared_utils/slack_client.py,sha256=jvkLeBMA8WHOqdRzCEpG42FfBlMeWue8bZ1g1ZxF-UI,40304
|
|
18
|
+
nui_shared_utils/slack_formatter.py,sha256=QT9VXApE0q09n_PmEVvaLCSfLW24vmowlCXF3Dzimfk,10850
|
|
19
19
|
nui_shared_utils/snowflake_client.py,sha256=e3jH6_2mMqbtP1TE8-mUFFYrfGW9eqUPT5tlcgeCqx8,16181
|
|
20
|
-
nui_shared_utils/timezone.py,sha256=
|
|
20
|
+
nui_shared_utils/timezone.py,sha256=gTiSK5NUG0Z8UY_AsB4ktIzLrZ0wwTXtdTUH-Npdvik,3837
|
|
21
21
|
nui_shared_utils/utils.py,sha256=gOpw80tZGC24QsyesR8EySRODY-TFXG8cXUvggPBsRI,8184
|
|
22
22
|
nui_shared_utils/logging/__init__.py,sha256=gVC853q3ja4RhcBVbf5jKO10lFX4upfsTvkKZx4wu54,1805
|
|
23
23
|
nui_shared_utils/logging/binding.py,sha256=I5VNmtf4U2yt54aS4F0Djw4u22mfjwCpWvORUnJNkPU,13577
|
|
@@ -27,8 +27,8 @@ nui_shared_utils/slack_setup/__init__.py,sha256=OElyS3xk4F_YKH5uUUTDpN0ah1dOO3e5
|
|
|
27
27
|
nui_shared_utils/slack_setup/channel_creator.py,sha256=0gyCBIS0EC96SVn1Z2dD4Fpzk0xNdHSWyiCxoUQIUe8,10667
|
|
28
28
|
nui_shared_utils/slack_setup/channel_definitions.py,sha256=atfz5ZhpqefOeLh1gShbWd-TLzgjPmhv95bfuTdmZog,5676
|
|
29
29
|
nui_shared_utils/slack_setup/setup_helpers.py,sha256=pzzXMs12GI9sdZttWeYzgLPgC0xqPz7ZLHM1GUPNNXc,7219
|
|
30
|
-
nui_python_shared_utils-2.
|
|
31
|
-
nui_python_shared_utils-2.
|
|
32
|
-
nui_python_shared_utils-2.
|
|
33
|
-
nui_python_shared_utils-2.
|
|
34
|
-
nui_python_shared_utils-2.
|
|
30
|
+
nui_python_shared_utils-2.2.dist-info/METADATA,sha256=QsygJarXaIbwKBA4-jRoTNQdsUHPvdgA0vtiVHWGsBw,22688
|
|
31
|
+
nui_python_shared_utils-2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
32
|
+
nui_python_shared_utils-2.2.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
|
|
33
|
+
nui_python_shared_utils-2.2.dist-info/top_level.txt,sha256=YMRu5cnn3hxHoYq9wNvaI9atVHnTNVdswO5_cQSfnJI,17
|
|
34
|
+
nui_python_shared_utils-2.2.dist-info/RECORD,,
|
nui_shared_utils/__init__.py
CHANGED
|
@@ -87,6 +87,7 @@ _LAZY_EXPORTS = {
|
|
|
87
87
|
"get_lambda_environment_info": ("lambda_helpers", "get_lambda_environment_info"),
|
|
88
88
|
# Optional: Slack client (slack-sdk)
|
|
89
89
|
"SlackClient": ("slack_client", "SlackClient"),
|
|
90
|
+
"SentMessage": ("slack_client", "SentMessage"),
|
|
90
91
|
# Optional: Elasticsearch client + query builder
|
|
91
92
|
"ElasticsearchClient": ("es_client", "ElasticsearchClient"),
|
|
92
93
|
"ESQueryBuilder": ("es_query_builder", "ESQueryBuilder"),
|
|
@@ -265,7 +266,7 @@ if TYPE_CHECKING:
|
|
|
265
266
|
extract_cloudwatch_logs_from_kinesis,
|
|
266
267
|
)
|
|
267
268
|
from .lambda_helpers import get_lambda_environment_info
|
|
268
|
-
from .slack_client import SlackClient
|
|
269
|
+
from .slack_client import SentMessage, SlackClient
|
|
269
270
|
from .es_client import ElasticsearchClient
|
|
270
271
|
from .es_query_builder import (
|
|
271
272
|
ESQueryBuilder,
|
nui_shared_utils/es_client.py
CHANGED
|
@@ -97,24 +97,37 @@ class ElasticsearchClient(BaseClient, ServiceHealthMixin):
|
|
|
97
97
|
retry_on_timeout=self.client_config.get("retry_on_timeout", True),
|
|
98
98
|
)
|
|
99
99
|
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _body_with_size(body: Dict, size: int) -> Dict:
|
|
102
|
+
"""Return a copy of ``body`` carrying ``size``.
|
|
103
|
+
|
|
104
|
+
``size`` travels inside the request body rather than as a sibling keyword
|
|
105
|
+
argument, which is the one call shape every supported client major accepts.
|
|
106
|
+
On elasticsearch-py 8.x/9.x, passing ``body=`` alongside a body field as a
|
|
107
|
+
keyword folds that keyword into the caller's dict (mutating it) and raises
|
|
108
|
+
``ValueError`` if the key is already there, so reusing a body dict for two
|
|
109
|
+
searches breaks. A body that carries its own ``size`` is accepted unchanged
|
|
110
|
+
by 7.17, 8.x and 9.x, and leaves the caller's dict alone.
|
|
111
|
+
"""
|
|
112
|
+
return {**body, "size": size}
|
|
113
|
+
|
|
100
114
|
@handle_client_errors(default_return=[])
|
|
101
115
|
def search(self, index: str, body: Dict, size: int = 100) -> List[Dict]:
|
|
102
116
|
"""
|
|
103
117
|
Execute search query with error handling.
|
|
104
|
-
|
|
118
|
+
|
|
105
119
|
Args:
|
|
106
120
|
index: Index pattern to search
|
|
107
121
|
body: Elasticsearch query body
|
|
108
|
-
size: Maximum results to return
|
|
109
|
-
|
|
122
|
+
size: Maximum results to return (overrides any ``size`` in ``body``)
|
|
123
|
+
|
|
110
124
|
Returns:
|
|
111
125
|
List of hit documents
|
|
112
126
|
"""
|
|
113
127
|
def _search_operation():
|
|
114
128
|
response = self._service_client.search(
|
|
115
129
|
index=index,
|
|
116
|
-
body=body,
|
|
117
|
-
size=size,
|
|
130
|
+
body=self._body_with_size(body, size),
|
|
118
131
|
ignore_unavailable=True
|
|
119
132
|
)
|
|
120
133
|
return [hit["_source"] for hit in response["hits"]["hits"]]
|
|
@@ -141,8 +154,7 @@ class ElasticsearchClient(BaseClient, ServiceHealthMixin):
|
|
|
141
154
|
def _aggregate_operation():
|
|
142
155
|
response = self._service_client.search(
|
|
143
156
|
index=index,
|
|
144
|
-
body=body,
|
|
145
|
-
size=0, # Only need aggregations
|
|
157
|
+
body=self._body_with_size(body, 0), # Only need aggregations
|
|
146
158
|
ignore_unavailable=True
|
|
147
159
|
)
|
|
148
160
|
return response.get("aggregations", {})
|
|
@@ -374,7 +386,9 @@ class ElasticsearchClient(BaseClient, ServiceHealthMixin):
|
|
|
374
386
|
format="json",
|
|
375
387
|
h="index,health,status,docs.count,store.size"
|
|
376
388
|
)
|
|
377
|
-
return response
|
|
389
|
+
# 8.x/9.x return a response wrapper around the JSON array; normalise to
|
|
390
|
+
# a plain list so callers get the same type on every client major.
|
|
391
|
+
return list(response) if response else []
|
|
378
392
|
|
|
379
393
|
return self._execute_with_error_handling(
|
|
380
394
|
"get_indices_info",
|
nui_shared_utils/slack_client.py
CHANGED
|
@@ -4,10 +4,12 @@ Refactored Slack client using BaseClient for DRY code patterns.
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import logging
|
|
7
|
-
from
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, List, Dict, Optional, Union
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from slack_sdk import WebClient
|
|
10
11
|
from slack_sdk.errors import SlackApiError
|
|
12
|
+
from slack_sdk.web.slack_response import SlackResponse
|
|
11
13
|
from datetime import datetime
|
|
12
14
|
|
|
13
15
|
try:
|
|
@@ -30,9 +32,60 @@ DEFAULT_ACCOUNT_NAMES = {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SentMessage:
|
|
37
|
+
"""Slack's answer to a ``chat.postMessage`` call.
|
|
38
|
+
|
|
39
|
+
``ts`` is the handle Slack addresses a message by. Keep it to reply under
|
|
40
|
+
the message (:meth:`SlackClient.send_thread_reply`), edit it in place
|
|
41
|
+
(:meth:`SlackClient.update_message`) or annotate it
|
|
42
|
+
(:meth:`SlackClient.add_reaction`). Without it those three are unreachable,
|
|
43
|
+
which is why :meth:`SlackClient.post_message` exists alongside the
|
|
44
|
+
bool-returning :meth:`SlackClient.send_message`.
|
|
45
|
+
|
|
46
|
+
Truthiness follows ``ok``. Note that a SentMessage returned by
|
|
47
|
+
:meth:`SlackClient.post_message` against a real client is always ``ok``,
|
|
48
|
+
because ``slack_sdk`` raises on a rejection rather than returning one, so
|
|
49
|
+
``if sent:`` is not a failure guard there. ``ok`` earns its place for a
|
|
50
|
+
client that does not validate, and for :meth:`SlackClient.send_message`,
|
|
51
|
+
which converts this type into its bool.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
ok: Whether Slack accepted the message.
|
|
55
|
+
ts: Message timestamp, or None when Slack did not return one.
|
|
56
|
+
channel: Channel Slack recorded the message against. Worth keeping
|
|
57
|
+
rather than reusing the channel you passed: posting to a user ID
|
|
58
|
+
or channel name resolves to a different id than the one you sent.
|
|
59
|
+
response: The underlying ``slack_sdk`` response, for fields this type
|
|
60
|
+
does not name.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
ok: bool
|
|
64
|
+
ts: Optional[str] = None
|
|
65
|
+
channel: Optional[str] = None
|
|
66
|
+
response: Any = None
|
|
67
|
+
|
|
68
|
+
def __bool__(self) -> bool:
|
|
69
|
+
return self.ok
|
|
70
|
+
|
|
71
|
+
|
|
33
72
|
class SlackClient(BaseClient, ServiceHealthMixin):
|
|
34
73
|
"""
|
|
35
74
|
Refactored Slack client with standardized patterns and reduced duplication.
|
|
75
|
+
|
|
76
|
+
Two families of write method, differing only in what they give back:
|
|
77
|
+
|
|
78
|
+
- ``send_message`` / ``send_thread_reply`` / ``update_message`` /
|
|
79
|
+
``send_file`` / ``add_reaction`` return a bool and swallow every error.
|
|
80
|
+
- ``post_message`` / ``post_thread_reply`` / ``post_update`` / ``post_file``
|
|
81
|
+
return Slack's answer and **raise** on every failure, rejections included.
|
|
82
|
+
The shared ``post_`` prefix marks that return contract, not the Slack call:
|
|
83
|
+
``post_update`` edits a message in place rather than posting a new one.
|
|
84
|
+
|
|
85
|
+
Reach for a ``post_*`` method when you need the message's ``ts`` (the only
|
|
86
|
+
handle an edit, reaction or thread reply accepts) or the error detail behind
|
|
87
|
+
a failure. Reach for the others when a failed notification should not take
|
|
88
|
+
the caller down with it. Each method's docstring names its counterpart.
|
|
36
89
|
"""
|
|
37
90
|
|
|
38
91
|
def __init__(
|
|
@@ -380,6 +433,145 @@ class SlackClient(BaseClient, ServiceHealthMixin):
|
|
|
380
433
|
}]
|
|
381
434
|
}]
|
|
382
435
|
|
|
436
|
+
def _blocks_with_header(
|
|
437
|
+
self,
|
|
438
|
+
blocks: Optional[List[Dict]],
|
|
439
|
+
include_lambda_header: bool,
|
|
440
|
+
event_type: Optional[str],
|
|
441
|
+
) -> Optional[List[Dict]]:
|
|
442
|
+
"""Prepend the standard context header to ``blocks`` when requested.
|
|
443
|
+
|
|
444
|
+
Shared by send_message and post_message so both head a message the same
|
|
445
|
+
way. Consumers used to rebuild this by calling the private header
|
|
446
|
+
builders directly; call post_message instead.
|
|
447
|
+
"""
|
|
448
|
+
if not include_lambda_header:
|
|
449
|
+
return blocks
|
|
450
|
+
|
|
451
|
+
if self._lambda_context["function_name"] != "Unknown":
|
|
452
|
+
header_blocks = self._create_lambda_header_block(event_type=event_type)
|
|
453
|
+
else:
|
|
454
|
+
header_blocks = self._create_local_header_block(event_type=event_type)
|
|
455
|
+
|
|
456
|
+
return header_blocks + (blocks or [])
|
|
457
|
+
|
|
458
|
+
def _chat_post(
|
|
459
|
+
self,
|
|
460
|
+
channel: str,
|
|
461
|
+
text: str,
|
|
462
|
+
blocks: Optional[List[Dict]],
|
|
463
|
+
attachments: Optional[List[Dict]],
|
|
464
|
+
include_lambda_header: bool,
|
|
465
|
+
event_type: Optional[str],
|
|
466
|
+
unfurl_links: Optional[bool],
|
|
467
|
+
unfurl_media: Optional[bool],
|
|
468
|
+
) -> SentMessage:
|
|
469
|
+
"""Build the payload, post it, log the outcome, return Slack's answer."""
|
|
470
|
+
kwargs: Dict[str, Any] = dict(
|
|
471
|
+
channel=channel,
|
|
472
|
+
text=text,
|
|
473
|
+
blocks=self._blocks_with_header(blocks, include_lambda_header, event_type),
|
|
474
|
+
attachments=attachments,
|
|
475
|
+
)
|
|
476
|
+
# Forward unfurl controls only when explicitly set, so existing callers
|
|
477
|
+
# keep Slack's default behavior (and their call assertions stay unchanged).
|
|
478
|
+
if unfurl_links is not None:
|
|
479
|
+
kwargs["unfurl_links"] = unfurl_links
|
|
480
|
+
if unfurl_media is not None:
|
|
481
|
+
kwargs["unfurl_media"] = unfurl_media
|
|
482
|
+
|
|
483
|
+
response = self._service_client.chat_postMessage(**kwargs)
|
|
484
|
+
|
|
485
|
+
sent = SentMessage(
|
|
486
|
+
ok=bool(response.get("ok", False)),
|
|
487
|
+
ts=response.get("ts"),
|
|
488
|
+
channel=response.get("channel"),
|
|
489
|
+
response=response,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if sent.ok:
|
|
493
|
+
log.info("Slack message sent successfully", extra={"channel": channel, "ts": sent.ts})
|
|
494
|
+
else:
|
|
495
|
+
log.error("Slack API returned error", extra={"error": response.get("error", "Unknown error")})
|
|
496
|
+
return sent
|
|
497
|
+
|
|
498
|
+
@handle_client_errors(reraise=True)
|
|
499
|
+
def post_message(
|
|
500
|
+
self,
|
|
501
|
+
channel: str,
|
|
502
|
+
text: str,
|
|
503
|
+
blocks: Optional[List[Dict]] = None,
|
|
504
|
+
attachments: Optional[List[Dict]] = None,
|
|
505
|
+
include_lambda_header: bool = True,
|
|
506
|
+
event_type: Optional[str] = None,
|
|
507
|
+
unfurl_links: Optional[bool] = None,
|
|
508
|
+
unfurl_media: Optional[bool] = None,
|
|
509
|
+
) -> SentMessage:
|
|
510
|
+
"""
|
|
511
|
+
Send a message and return Slack's response, including the ``ts``. Raises.
|
|
512
|
+
|
|
513
|
+
**Like every ``post_*`` method here, it raises rather than swallowing.**
|
|
514
|
+
The ``send_*`` / ``update_message`` family logs and returns a default
|
|
515
|
+
instead; this one lets the exception reach you, because a caller holding
|
|
516
|
+
a ``ts`` generally needs the rejection code, HTTP status and retry headers
|
|
517
|
+
that a bare False cannot express. Wrap the call accordingly, or use
|
|
518
|
+
send_message if you want the swallowing behaviour.
|
|
519
|
+
|
|
520
|
+
**It returns on success and raises on every failure**, Slack's own
|
|
521
|
+
rejections included: ``slack_sdk`` validates each response before handing
|
|
522
|
+
it back, so ``{"ok": false, "error": ...}`` arrives as a ``SlackApiError``
|
|
523
|
+
carrying that detail on ``err.response``, never as a returned value. So do
|
|
524
|
+
not write ``if sent:`` as a failure guard, because it never runs; catch
|
|
525
|
+
instead. (``SentMessage.ok`` stays for defensive handling of a client that
|
|
526
|
+
does not validate, such as a test double returning a plain dict; against a
|
|
527
|
+
real client a returned SentMessage is always ``ok``.)
|
|
528
|
+
|
|
529
|
+
Otherwise it is send_message: same arguments, same standard header. The
|
|
530
|
+
difference is what comes back. send_message answers True/False and
|
|
531
|
+
discards the ``ts``, which is the only handle send_thread_reply,
|
|
532
|
+
update_message and add_reaction accept, so a caller that may later thread
|
|
533
|
+
under, edit or react to its own message wants this one.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
channel: Channel ID
|
|
537
|
+
text: Fallback text
|
|
538
|
+
blocks: Rich formatted blocks
|
|
539
|
+
attachments: Legacy attachment objects (supports color sidebars)
|
|
540
|
+
include_lambda_header: Whether to include context header
|
|
541
|
+
event_type: Optional event type label for header (e.g., "Scheduled", "API", "SQS")
|
|
542
|
+
unfurl_links: Override Slack link unfurling. None leaves Slack's default;
|
|
543
|
+
False suppresses link preview cards (links stay clickable)
|
|
544
|
+
unfurl_media: Override Slack media unfurling. None leaves Slack's default;
|
|
545
|
+
False suppresses media preview cards
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
SentMessage carrying ok, ts, channel and the raw response.
|
|
549
|
+
|
|
550
|
+
Raises:
|
|
551
|
+
slack_sdk.errors.SlackApiError: Slack rejected the call, at the
|
|
552
|
+
transport layer or by answering ``ok: false``. ``err.response``
|
|
553
|
+
carries the error code, status and any retry headers. Any other
|
|
554
|
+
exception raised by the underlying client propagates too.
|
|
555
|
+
|
|
556
|
+
Example:
|
|
557
|
+
sent = client.post_message("C123", "Build started")
|
|
558
|
+
client.send_thread_reply(sent.channel, sent.ts, "Build finished")
|
|
559
|
+
"""
|
|
560
|
+
return self._execute_with_error_handling(
|
|
561
|
+
"post_message",
|
|
562
|
+
lambda: self._chat_post(
|
|
563
|
+
channel=channel,
|
|
564
|
+
text=text,
|
|
565
|
+
blocks=blocks,
|
|
566
|
+
attachments=attachments,
|
|
567
|
+
include_lambda_header=include_lambda_header,
|
|
568
|
+
event_type=event_type,
|
|
569
|
+
unfurl_links=unfurl_links,
|
|
570
|
+
unfurl_media=unfurl_media,
|
|
571
|
+
),
|
|
572
|
+
channel=channel,
|
|
573
|
+
)
|
|
574
|
+
|
|
383
575
|
@handle_client_errors(default_return=False)
|
|
384
576
|
def send_message(
|
|
385
577
|
self,
|
|
@@ -409,103 +601,181 @@ class SlackClient(BaseClient, ServiceHealthMixin):
|
|
|
409
601
|
|
|
410
602
|
Returns:
|
|
411
603
|
True if successful, False otherwise
|
|
412
|
-
"""
|
|
413
|
-
def _send_operation():
|
|
414
|
-
# Add context header if requested
|
|
415
|
-
if include_lambda_header:
|
|
416
|
-
if self._lambda_context["function_name"] != "Unknown":
|
|
417
|
-
header_blocks = self._create_lambda_header_block(event_type=event_type)
|
|
418
|
-
else:
|
|
419
|
-
header_blocks = self._create_local_header_block(event_type=event_type)
|
|
420
604
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
kwargs = dict(
|
|
605
|
+
See Also:
|
|
606
|
+
post_message: same arguments, returns Slack's response (with the
|
|
607
|
+
``ts``) instead of a bool, and raises rather than swallowing.
|
|
608
|
+
"""
|
|
609
|
+
sent = self._execute_with_error_handling(
|
|
610
|
+
"send_message",
|
|
611
|
+
lambda: self._chat_post(
|
|
429
612
|
channel=channel,
|
|
430
613
|
text=text,
|
|
431
|
-
blocks=
|
|
614
|
+
blocks=blocks,
|
|
432
615
|
attachments=attachments,
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if response["ok"]:
|
|
443
|
-
log.info(
|
|
444
|
-
"Slack message sent successfully",
|
|
445
|
-
extra={"channel": channel, "ts": response["ts"]}
|
|
446
|
-
)
|
|
447
|
-
return True
|
|
448
|
-
else:
|
|
449
|
-
log.error(
|
|
450
|
-
"Slack API returned error",
|
|
451
|
-
extra={"error": response.get("error", "Unknown error")}
|
|
452
|
-
)
|
|
453
|
-
return False
|
|
616
|
+
include_lambda_header=include_lambda_header,
|
|
617
|
+
event_type=event_type,
|
|
618
|
+
unfurl_links=unfurl_links,
|
|
619
|
+
unfurl_media=unfurl_media,
|
|
620
|
+
),
|
|
621
|
+
channel=channel,
|
|
622
|
+
)
|
|
623
|
+
return bool(sent)
|
|
454
624
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
625
|
+
def _upload_file(
|
|
626
|
+
self,
|
|
627
|
+
channel: str,
|
|
628
|
+
content: Union[str, bytes],
|
|
629
|
+
filename: str,
|
|
630
|
+
title: Optional[str],
|
|
631
|
+
thread_ts: Optional[str],
|
|
632
|
+
) -> SlackResponse:
|
|
633
|
+
"""Upload a file and log the outcome, returning Slack's raw response.
|
|
634
|
+
|
|
635
|
+
Shared by send_file and post_file. Unlike the message helpers this does
|
|
636
|
+
not build a SentMessage: an upload has no single message ``ts``, so one
|
|
637
|
+
would have to be invented.
|
|
638
|
+
"""
|
|
639
|
+
kwargs: Dict[str, Any] = dict(
|
|
640
|
+
channel=channel,
|
|
641
|
+
content=content,
|
|
642
|
+
filename=filename,
|
|
643
|
+
title=title or filename,
|
|
459
644
|
)
|
|
645
|
+
if thread_ts is not None:
|
|
646
|
+
kwargs["thread_ts"] = thread_ts
|
|
647
|
+
|
|
648
|
+
response = self._service_client.files_upload_v2(**kwargs)
|
|
649
|
+
|
|
650
|
+
if response.get("ok", False):
|
|
651
|
+
log.info("File uploaded successfully", extra={"channel": channel, "file_name": filename})
|
|
652
|
+
else:
|
|
653
|
+
log.error("Slack file upload failed", extra={"error": response.get("error", "Unknown error")})
|
|
654
|
+
return response
|
|
460
655
|
|
|
461
656
|
@handle_client_errors(default_return=False)
|
|
462
657
|
def send_file(
|
|
463
658
|
self,
|
|
464
659
|
channel: str,
|
|
465
|
-
content: str,
|
|
660
|
+
content: Union[str, bytes],
|
|
466
661
|
filename: str,
|
|
467
|
-
title: Optional[str] = None
|
|
662
|
+
title: Optional[str] = None,
|
|
663
|
+
thread_ts: Optional[str] = None,
|
|
468
664
|
) -> bool:
|
|
469
665
|
"""
|
|
470
666
|
Upload file to Slack channel.
|
|
471
|
-
|
|
667
|
+
|
|
472
668
|
Args:
|
|
473
|
-
channel: Channel ID
|
|
474
|
-
|
|
669
|
+
channel: Channel ID. A name will not do: the SDK forwards this as
|
|
670
|
+
``channel_id`` to ``files.completeUploadExternal``, so a name
|
|
671
|
+
uploads the bytes and then fails to share them.
|
|
672
|
+
content: File content (str or bytes)
|
|
475
673
|
filename: File name
|
|
476
674
|
title: Optional title
|
|
477
|
-
|
|
675
|
+
thread_ts: Optional parent message timestamp, to upload into a thread
|
|
676
|
+
|
|
478
677
|
Returns:
|
|
479
678
|
True if successful, False otherwise
|
|
679
|
+
|
|
680
|
+
See Also:
|
|
681
|
+
post_file: same arguments, returns Slack's response and raises.
|
|
480
682
|
"""
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
683
|
+
response = self._execute_with_error_handling(
|
|
684
|
+
"send_file",
|
|
685
|
+
lambda: self._upload_file(channel, content, filename, title, thread_ts),
|
|
686
|
+
channel=channel,
|
|
687
|
+
# not `filename`: logging reserves it on LogRecord, and passing it
|
|
688
|
+
# raises a KeyError that replaces whatever error we were reporting.
|
|
689
|
+
file_name=filename,
|
|
690
|
+
)
|
|
691
|
+
return bool(response.get("ok", False))
|
|
488
692
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
693
|
+
@handle_client_errors(reraise=True)
|
|
694
|
+
def post_file(
|
|
695
|
+
self,
|
|
696
|
+
channel: str,
|
|
697
|
+
content: Union[str, bytes],
|
|
698
|
+
filename: str,
|
|
699
|
+
title: Optional[str] = None,
|
|
700
|
+
thread_ts: Optional[str] = None,
|
|
701
|
+
) -> SlackResponse:
|
|
702
|
+
"""
|
|
703
|
+
Upload a file and return Slack's response. Raises.
|
|
704
|
+
|
|
705
|
+
Response-returning counterpart of send_file, following the same rule as
|
|
706
|
+
post_message: it returns on success and raises on every failure. The
|
|
707
|
+
return is ``files_upload_v2``'s completion response rather than a
|
|
708
|
+
SentMessage, because an upload has no single message ``ts``; read
|
|
709
|
+
``response["files"]`` for what was created.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
channel: Channel ID. A name will not do: the SDK forwards this as
|
|
713
|
+
``channel_id`` to ``files.completeUploadExternal``, so a name
|
|
714
|
+
uploads the bytes and then fails to share them.
|
|
715
|
+
content: File content (str or bytes)
|
|
716
|
+
filename: File name
|
|
717
|
+
title: Optional title
|
|
718
|
+
thread_ts: Optional parent message timestamp, to upload into a thread
|
|
501
719
|
|
|
720
|
+
Returns:
|
|
721
|
+
``files_upload_v2``'s completion response.
|
|
722
|
+
|
|
723
|
+
Raises:
|
|
724
|
+
slack_sdk.errors.SlackApiError: Slack rejected the upload. Any other
|
|
725
|
+
exception from the underlying client propagates too.
|
|
726
|
+
"""
|
|
502
727
|
return self._execute_with_error_handling(
|
|
503
|
-
"
|
|
504
|
-
|
|
728
|
+
"post_file",
|
|
729
|
+
lambda: self._upload_file(channel, content, filename, title, thread_ts),
|
|
505
730
|
channel=channel,
|
|
506
|
-
filename
|
|
731
|
+
# not `filename`: logging reserves it on LogRecord, and passing it
|
|
732
|
+
# raises a KeyError that replaces whatever error we were reporting.
|
|
733
|
+
file_name=filename,
|
|
507
734
|
)
|
|
508
735
|
|
|
736
|
+
def _chat_post_thread_reply(
|
|
737
|
+
self,
|
|
738
|
+
channel: str,
|
|
739
|
+
thread_ts: str,
|
|
740
|
+
text: str,
|
|
741
|
+
blocks: Optional[List[Dict]],
|
|
742
|
+
include_lambda_header: bool,
|
|
743
|
+
event_type: Optional[str],
|
|
744
|
+
) -> SentMessage:
|
|
745
|
+
"""Post a thread reply, log the outcome, return Slack's answer.
|
|
746
|
+
|
|
747
|
+
Shared by send_thread_reply and post_thread_reply. Note this heads a
|
|
748
|
+
reply only inside Lambda: unlike a top-level post it has never added the
|
|
749
|
+
local header, and that is preserved rather than unified.
|
|
750
|
+
"""
|
|
751
|
+
blocks_with_header = blocks
|
|
752
|
+
if include_lambda_header and self._lambda_context["function_name"] != "Unknown":
|
|
753
|
+
header_blocks = self._create_lambda_header_block(event_type=event_type)
|
|
754
|
+
blocks_with_header = header_blocks + (blocks or [])
|
|
755
|
+
|
|
756
|
+
response = self._service_client.chat_postMessage(
|
|
757
|
+
channel=channel,
|
|
758
|
+
thread_ts=thread_ts,
|
|
759
|
+
text=text,
|
|
760
|
+
blocks=blocks_with_header,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
sent = SentMessage(
|
|
764
|
+
ok=bool(response.get("ok", False)),
|
|
765
|
+
ts=response.get("ts"),
|
|
766
|
+
channel=response.get("channel"),
|
|
767
|
+
response=response,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
if sent.ok:
|
|
771
|
+
log.info(
|
|
772
|
+
"Thread reply sent successfully",
|
|
773
|
+
extra={"channel": channel, "thread_ts": thread_ts, "reply_ts": sent.ts},
|
|
774
|
+
)
|
|
775
|
+
else:
|
|
776
|
+
log.error("Failed to send thread reply", extra={"error": response.get("error", "Unknown error")})
|
|
777
|
+
return sent
|
|
778
|
+
|
|
509
779
|
@handle_client_errors(default_return=False)
|
|
510
780
|
def send_thread_reply(
|
|
511
781
|
self,
|
|
@@ -529,48 +799,99 @@ class SlackClient(BaseClient, ServiceHealthMixin):
|
|
|
529
799
|
|
|
530
800
|
Returns:
|
|
531
801
|
True if successful, False otherwise
|
|
532
|
-
"""
|
|
533
|
-
def _reply_operation():
|
|
534
|
-
# Add header if requested (uncommon for thread replies)
|
|
535
|
-
blocks_with_header = blocks
|
|
536
|
-
if include_lambda_header and self._lambda_context["function_name"] != "Unknown":
|
|
537
|
-
header_blocks = self._create_lambda_header_block(event_type=event_type)
|
|
538
|
-
if blocks:
|
|
539
|
-
blocks_with_header = header_blocks + blocks
|
|
540
|
-
else:
|
|
541
|
-
blocks_with_header = header_blocks
|
|
542
802
|
|
|
543
|
-
|
|
803
|
+
See Also:
|
|
804
|
+
post_thread_reply: same arguments, returns Slack's response (with the
|
|
805
|
+
reply's own ``ts``) instead of a bool, and raises rather than swallowing.
|
|
806
|
+
"""
|
|
807
|
+
sent = self._execute_with_error_handling(
|
|
808
|
+
"send_thread_reply",
|
|
809
|
+
lambda: self._chat_post_thread_reply(
|
|
544
810
|
channel=channel,
|
|
545
811
|
thread_ts=thread_ts,
|
|
546
812
|
text=text,
|
|
547
|
-
blocks=
|
|
548
|
-
|
|
813
|
+
blocks=blocks,
|
|
814
|
+
include_lambda_header=include_lambda_header,
|
|
815
|
+
event_type=event_type,
|
|
816
|
+
),
|
|
817
|
+
channel=channel,
|
|
818
|
+
thread_ts=thread_ts,
|
|
819
|
+
)
|
|
820
|
+
return bool(sent)
|
|
549
821
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
822
|
+
@handle_client_errors(reraise=True)
|
|
823
|
+
def post_thread_reply(
|
|
824
|
+
self,
|
|
825
|
+
channel: str,
|
|
826
|
+
thread_ts: str,
|
|
827
|
+
text: str,
|
|
828
|
+
blocks: Optional[List[Dict]] = None,
|
|
829
|
+
include_lambda_header: bool = False,
|
|
830
|
+
event_type: Optional[str] = None,
|
|
831
|
+
) -> SentMessage:
|
|
832
|
+
"""
|
|
833
|
+
Send a thread reply and return Slack's response, including its own ``ts``.
|
|
834
|
+
|
|
835
|
+
Response-returning counterpart of send_thread_reply, following the same
|
|
836
|
+
rule as post_message: it returns on success and raises on every failure,
|
|
837
|
+
Slack's rejections included. A reply has a ``ts`` of its own, so this is
|
|
838
|
+
what you want when a later call edits or reacts to the reply itself.
|
|
839
|
+
|
|
840
|
+
Args:
|
|
841
|
+
channel: Channel ID
|
|
842
|
+
thread_ts: Parent message timestamp
|
|
843
|
+
text: Reply text
|
|
844
|
+
blocks: Optional blocks
|
|
845
|
+
include_lambda_header: Whether to include header
|
|
846
|
+
event_type: Optional event type label for header (e.g., "Scheduled", "API", "SQS")
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
SentMessage for the reply, whose ``ts`` is the reply's, not the parent's.
|
|
566
850
|
|
|
851
|
+
Raises:
|
|
852
|
+
slack_sdk.errors.SlackApiError: Slack rejected the call. Any other
|
|
853
|
+
exception from the underlying client propagates too.
|
|
854
|
+
"""
|
|
567
855
|
return self._execute_with_error_handling(
|
|
568
|
-
"
|
|
569
|
-
|
|
856
|
+
"post_thread_reply",
|
|
857
|
+
lambda: self._chat_post_thread_reply(
|
|
858
|
+
channel=channel,
|
|
859
|
+
thread_ts=thread_ts,
|
|
860
|
+
text=text,
|
|
861
|
+
blocks=blocks,
|
|
862
|
+
include_lambda_header=include_lambda_header,
|
|
863
|
+
event_type=event_type,
|
|
864
|
+
),
|
|
570
865
|
channel=channel,
|
|
571
|
-
thread_ts=thread_ts
|
|
866
|
+
thread_ts=thread_ts,
|
|
572
867
|
)
|
|
573
868
|
|
|
869
|
+
def _chat_update(
|
|
870
|
+
self,
|
|
871
|
+
channel: str,
|
|
872
|
+
ts: str,
|
|
873
|
+
text: str,
|
|
874
|
+
blocks: Optional[List[Dict]],
|
|
875
|
+
) -> SentMessage:
|
|
876
|
+
"""Edit a message, log the outcome, return Slack's answer.
|
|
877
|
+
|
|
878
|
+
Shared by update_message and post_update.
|
|
879
|
+
"""
|
|
880
|
+
response = self._service_client.chat_update(channel=channel, ts=ts, text=text, blocks=blocks)
|
|
881
|
+
|
|
882
|
+
sent = SentMessage(
|
|
883
|
+
ok=bool(response.get("ok", False)),
|
|
884
|
+
ts=response.get("ts"),
|
|
885
|
+
channel=response.get("channel"),
|
|
886
|
+
response=response,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
if sent.ok:
|
|
890
|
+
log.info("Message updated successfully", extra={"channel": channel, "ts": ts})
|
|
891
|
+
else:
|
|
892
|
+
log.error("Failed to update message", extra={"error": response.get("error", "Unknown error")})
|
|
893
|
+
return sent
|
|
894
|
+
|
|
574
895
|
@handle_client_errors(default_return=False)
|
|
575
896
|
def update_message(
|
|
576
897
|
self,
|
|
@@ -581,36 +902,65 @@ class SlackClient(BaseClient, ServiceHealthMixin):
|
|
|
581
902
|
) -> bool:
|
|
582
903
|
"""
|
|
583
904
|
Update existing message.
|
|
584
|
-
|
|
905
|
+
|
|
585
906
|
Args:
|
|
586
907
|
channel: Channel ID
|
|
587
908
|
ts: Message timestamp
|
|
588
909
|
text: New text
|
|
589
910
|
blocks: New blocks
|
|
590
|
-
|
|
911
|
+
|
|
591
912
|
Returns:
|
|
592
913
|
True if successful, False otherwise
|
|
914
|
+
|
|
915
|
+
See Also:
|
|
916
|
+
post_update: same arguments, returns Slack's response instead of a
|
|
917
|
+
bool, and raises rather than swallowing.
|
|
593
918
|
"""
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
919
|
+
sent = self._execute_with_error_handling(
|
|
920
|
+
"update_message",
|
|
921
|
+
lambda: self._chat_update(channel=channel, ts=ts, text=text, blocks=blocks),
|
|
922
|
+
channel=channel,
|
|
923
|
+
ts=ts,
|
|
924
|
+
)
|
|
925
|
+
return bool(sent)
|
|
601
926
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
927
|
+
@handle_client_errors(reraise=True)
|
|
928
|
+
def post_update(
|
|
929
|
+
self,
|
|
930
|
+
channel: str,
|
|
931
|
+
ts: str,
|
|
932
|
+
text: str,
|
|
933
|
+
blocks: Optional[List[Dict]] = None,
|
|
934
|
+
) -> SentMessage:
|
|
935
|
+
"""
|
|
936
|
+
Edit an existing message in place and return Slack's response. Raises.
|
|
937
|
+
|
|
938
|
+
This edits; it does not post anything new, despite the ``post_`` prefix
|
|
939
|
+
the response-returning family shares. It is the counterpart of
|
|
940
|
+
update_message and follows the same rule as post_message: it returns on
|
|
941
|
+
success and raises on every failure. Use it when an edit failing silently
|
|
942
|
+
would be wrong, or when you need the edited message's fields back.
|
|
943
|
+
|
|
944
|
+
Args:
|
|
945
|
+
channel: Channel ID. Pass the ``channel`` from the original post's
|
|
946
|
+
SentMessage, not the name you posted to: ``chat.update`` wants
|
|
947
|
+
the id.
|
|
948
|
+
ts: Message timestamp
|
|
949
|
+
text: New text
|
|
950
|
+
blocks: New blocks
|
|
951
|
+
|
|
952
|
+
Returns:
|
|
953
|
+
SentMessage for the edited message.
|
|
608
954
|
|
|
955
|
+
Raises:
|
|
956
|
+
slack_sdk.errors.SlackApiError: Slack rejected the edit. Any other
|
|
957
|
+
exception from the underlying client propagates too.
|
|
958
|
+
"""
|
|
609
959
|
return self._execute_with_error_handling(
|
|
610
|
-
"
|
|
611
|
-
|
|
960
|
+
"post_update",
|
|
961
|
+
lambda: self._chat_update(channel=channel, ts=ts, text=text, blocks=blocks),
|
|
612
962
|
channel=channel,
|
|
613
|
-
ts=ts
|
|
963
|
+
ts=ts,
|
|
614
964
|
)
|
|
615
965
|
|
|
616
966
|
@handle_client_errors(default_return=False)
|
|
@@ -4,8 +4,9 @@ Provides a builder pattern for creating Slack Block Kit messages.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from typing import Dict, List, Any, Optional, Union
|
|
7
|
-
from datetime import datetime, timedelta
|
|
8
|
-
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from .timezone import NZ_TZ
|
|
9
10
|
|
|
10
11
|
# Severity emoji mapping
|
|
11
12
|
SEVERITY_EMOJI = {"critical": "🚨", "warning": "⚠️", "info": "ℹ️", "success": "✅", "error": "❌"}
|
|
@@ -42,25 +43,23 @@ def format_number(value: Union[int, float], decimals: int = 0) -> str:
|
|
|
42
43
|
def format_nz_time(dt: Optional[datetime] = None) -> str:
|
|
43
44
|
"""Format datetime in NZ timezone."""
|
|
44
45
|
if dt is None:
|
|
45
|
-
dt = datetime.
|
|
46
|
+
dt = datetime.now(timezone.utc)
|
|
46
47
|
elif not dt.tzinfo:
|
|
47
|
-
dt = dt.replace(tzinfo=
|
|
48
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
nz_time = dt.astimezone(nz_tz)
|
|
50
|
+
nz_time = dt.astimezone(NZ_TZ)
|
|
51
51
|
return nz_time.strftime("%I:%M %p %Z")
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
def format_date_range(start: datetime, end: datetime) -> str:
|
|
55
55
|
"""Format a date range in NZ timezone."""
|
|
56
|
-
nz_tz = pytz.timezone("Pacific/Auckland")
|
|
57
56
|
if not start.tzinfo:
|
|
58
|
-
start = start.replace(tzinfo=
|
|
57
|
+
start = start.replace(tzinfo=timezone.utc)
|
|
59
58
|
if not end.tzinfo:
|
|
60
|
-
end = end.replace(tzinfo=
|
|
59
|
+
end = end.replace(tzinfo=timezone.utc)
|
|
61
60
|
|
|
62
|
-
start_nz = start.astimezone(
|
|
63
|
-
end_nz = end.astimezone(
|
|
61
|
+
start_nz = start.astimezone(NZ_TZ)
|
|
62
|
+
end_nz = end.astimezone(NZ_TZ)
|
|
64
63
|
|
|
65
64
|
if start_nz.date() == end_nz.date():
|
|
66
65
|
return f"{start_nz.strftime('%b %d')} {start_nz.strftime('%I:%M %p')} - {end_nz.strftime('%I:%M %p %Z')}"
|
nui_shared_utils/timezone.py
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Timezone utilities for multi-timezone handling (NZ primary, AUS/US/EUR secondary).
|
|
3
|
+
|
|
4
|
+
Zone data comes from the standard library's ``zoneinfo``, which reads the system
|
|
5
|
+
IANA database. Environments without one (Windows, minimal images such as Alpine)
|
|
6
|
+
need the ``tzdata`` package: `pip install nui-python-shared-utils[timezone]`.
|
|
3
7
|
"""
|
|
4
8
|
|
|
5
9
|
from datetime import datetime, timezone
|
|
6
|
-
import
|
|
7
|
-
from
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
from zoneinfo import ZoneInfo
|
|
8
12
|
|
|
9
13
|
# Supported timezones
|
|
10
|
-
NZ_TZ =
|
|
11
|
-
AUS_TZ =
|
|
12
|
-
US_EAST_TZ =
|
|
13
|
-
US_WEST_TZ =
|
|
14
|
-
EUR_TZ =
|
|
14
|
+
NZ_TZ = ZoneInfo("Pacific/Auckland") # Primary timezone
|
|
15
|
+
AUS_TZ = ZoneInfo("Australia/Sydney")
|
|
16
|
+
US_EAST_TZ = ZoneInfo("US/Eastern")
|
|
17
|
+
US_WEST_TZ = ZoneInfo("US/Pacific")
|
|
18
|
+
EUR_TZ = ZoneInfo("Europe/London")
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
def nz_time(utc_dt: Optional[datetime] = None) -> datetime:
|
|
@@ -28,7 +32,7 @@ def nz_time(utc_dt: Optional[datetime] = None) -> datetime:
|
|
|
28
32
|
utc_dt = datetime.now(timezone.utc)
|
|
29
33
|
|
|
30
34
|
if utc_dt.tzinfo is None:
|
|
31
|
-
utc_dt =
|
|
35
|
+
utc_dt = utc_dt.replace(tzinfo=timezone.utc)
|
|
32
36
|
|
|
33
37
|
return utc_dt.astimezone(NZ_TZ)
|
|
34
38
|
|
|
@@ -66,7 +70,7 @@ def format_multi_timezone(dt: Optional[datetime] = None, primary_tz: str = "nz",
|
|
|
66
70
|
dt = datetime.now(timezone.utc)
|
|
67
71
|
|
|
68
72
|
if dt.tzinfo is None:
|
|
69
|
-
dt =
|
|
73
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
70
74
|
|
|
71
75
|
# Timezone mapping
|
|
72
76
|
tz_map = {"nz": NZ_TZ, "aus": AUS_TZ, "us_east": US_EAST_TZ, "us_west": US_WEST_TZ, "eur": EUR_TZ}
|
{nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{nui_python_shared_utils-2.0.dist-info → nui_python_shared_utils-2.2.dist-info}/top_level.txt
RENAMED
|
File without changes
|