csp-adapter-slack 0.1.0__tar.gz → 0.3.0__tar.gz
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.
- {csp_adapter_slack-0.1.0 → csp_adapter_slack-0.3.0}/.gitignore +4 -0
- {csp_adapter_slack-0.1.0 → csp_adapter_slack-0.3.0}/PKG-INFO +17 -9
- {csp_adapter_slack-0.1.0 → csp_adapter_slack-0.3.0}/README.md +7 -1
- csp_adapter_slack-0.3.0/csp_adapter_slack/__init__.py +6 -0
- {csp_adapter_slack-0.1.0 → csp_adapter_slack-0.3.0}/csp_adapter_slack/adapter.py +82 -68
- csp_adapter_slack-0.3.0/csp_adapter_slack/adapter_config.py +37 -0
- csp_adapter_slack-0.3.0/csp_adapter_slack/examples/__init__.py +0 -0
- csp_adapter_slack-0.3.0/csp_adapter_slack/examples/hello.py +44 -0
- csp_adapter_slack-0.3.0/csp_adapter_slack/mention.py +10 -0
- csp_adapter_slack-0.3.0/csp_adapter_slack/message.py +40 -0
- {csp_adapter_slack-0.1.0 → csp_adapter_slack-0.3.0}/csp_adapter_slack/tests/test_adapter.py +15 -11
- {csp_adapter_slack-0.1.0 → csp_adapter_slack-0.3.0}/pyproject.toml +48 -18
- csp_adapter_slack-0.1.0/csp_adapter_slack/__init__.py +0 -3
- {csp_adapter_slack-0.1.0 → csp_adapter_slack-0.3.0}/LICENSE +0 -0
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: csp_adapter_slack
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.3.0
|
4
4
|
Summary: A csp adapter for slack
|
5
5
|
Project-URL: Repository, https://github.com/point72/csp-adapter-slack
|
6
6
|
Project-URL: Homepage, https://github.com/point72/csp-adapter-slack
|
@@ -213,24 +213,26 @@ Classifier: Framework :: Jupyter
|
|
213
213
|
Classifier: License :: OSI Approved :: Apache Software License
|
214
214
|
Classifier: Programming Language :: Python
|
215
215
|
Classifier: Programming Language :: Python :: 3
|
216
|
-
Classifier: Programming Language :: Python :: 3.8
|
217
216
|
Classifier: Programming Language :: Python :: 3.9
|
218
217
|
Classifier: Programming Language :: Python :: 3.10
|
219
218
|
Classifier: Programming Language :: Python :: 3.11
|
220
219
|
Classifier: Programming Language :: Python :: 3.12
|
221
|
-
|
220
|
+
Classifier: Programming Language :: Python :: 3.13
|
221
|
+
Requires-Python: >=3.9
|
222
222
|
Requires-Dist: csp
|
223
|
+
Requires-Dist: pydantic>=2
|
223
224
|
Requires-Dist: slack-sdk>=3
|
224
225
|
Provides-Extra: develop
|
225
|
-
Requires-Dist:
|
226
|
+
Requires-Dist: bump-my-version; extra == 'develop'
|
226
227
|
Requires-Dist: check-manifest; extra == 'develop'
|
227
|
-
Requires-Dist: codespell<2.
|
228
|
+
Requires-Dist: codespell<2.5,>=2.2.6; extra == 'develop'
|
228
229
|
Requires-Dist: hatchling; extra == 'develop'
|
230
|
+
Requires-Dist: mdformat-tables<1.1,>=1; extra == 'develop'
|
229
231
|
Requires-Dist: mdformat<0.8,>=0.7.17; extra == 'develop'
|
230
232
|
Requires-Dist: pytest; extra == 'develop'
|
231
233
|
Requires-Dist: pytest-cov; extra == 'develop'
|
232
|
-
Requires-Dist: ruff<0.
|
233
|
-
Requires-Dist: twine<
|
234
|
+
Requires-Dist: ruff<0.12,>=0.5; extra == 'develop'
|
235
|
+
Requires-Dist: twine<7,>=5; extra == 'develop'
|
234
236
|
Provides-Extra: test
|
235
237
|
Requires-Dist: pytest; extra == 'test'
|
236
238
|
Requires-Dist: pytest-cov; extra == 'test'
|
@@ -247,8 +249,14 @@ A [csp](https://github.com/point72/csp) adapter for [slack](https://slack.com)
|
|
247
249
|
|
248
250
|
## Features
|
249
251
|
|
252
|
+
The Slack adapter allows for reading and writing of messages from the [Slack](https://slack.com/) message platform using the [`slack-sdk`](https://tools.slack.dev/python-slack-sdk/).
|
253
|
+
|
250
254
|
[More information is available in our wiki](https://github.com/Point72/csp-adapter-slack/wiki)
|
251
255
|
|
256
|
+
## Chat Framework
|
257
|
+
|
258
|
+
[`csp-bot`](https://github.com/Point72/csp-bot) is a framework for writing cross-platform, command oriented chat bots.
|
259
|
+
|
252
260
|
## Installation
|
253
261
|
|
254
262
|
Install with `pip`:
|
@@ -265,4 +273,4 @@ conda install csp csp-adapter-slack -c conda-forge
|
|
265
273
|
|
266
274
|
## License
|
267
275
|
|
268
|
-
This software is licensed under the Apache 2.0 license. See the [LICENSE](LICENSE) file for details.
|
276
|
+
This software is licensed under the Apache 2.0 license. See the [LICENSE](https://github.com/Point72/csp-adapter-slack/blob/main/LICENSE) file for details.
|
@@ -9,8 +9,14 @@ A [csp](https://github.com/point72/csp) adapter for [slack](https://slack.com)
|
|
9
9
|
|
10
10
|
## Features
|
11
11
|
|
12
|
+
The Slack adapter allows for reading and writing of messages from the [Slack](https://slack.com/) message platform using the [`slack-sdk`](https://tools.slack.dev/python-slack-sdk/).
|
13
|
+
|
12
14
|
[More information is available in our wiki](https://github.com/Point72/csp-adapter-slack/wiki)
|
13
15
|
|
16
|
+
## Chat Framework
|
17
|
+
|
18
|
+
[`csp-bot`](https://github.com/Point72/csp-bot) is a framework for writing cross-platform, command oriented chat bots.
|
19
|
+
|
14
20
|
## Installation
|
15
21
|
|
16
22
|
Install with `pip`:
|
@@ -27,4 +33,4 @@ conda install csp csp-adapter-slack -c conda-forge
|
|
27
33
|
|
28
34
|
## License
|
29
35
|
|
30
|
-
This software is licensed under the Apache 2.0 license. See the [LICENSE](LICENSE) file for details.
|
36
|
+
This software is licensed under the Apache 2.0 license. See the [LICENSE](https://github.com/Point72/csp-adapter-slack/blob/main/LICENSE) file for details.
|
@@ -1,61 +1,37 @@
|
|
1
1
|
import threading
|
2
2
|
from logging import getLogger
|
3
3
|
from queue import Queue
|
4
|
-
from ssl import SSLContext
|
5
4
|
from threading import Thread
|
6
5
|
from time import sleep
|
7
|
-
from typing import Dict, List,
|
6
|
+
from typing import Dict, List, TypeVar
|
8
7
|
|
9
8
|
import csp
|
10
9
|
from csp.impl.adaptermanager import AdapterManagerImpl
|
11
10
|
from csp.impl.outputadapter import OutputAdapter
|
12
11
|
from csp.impl.pushadapter import PushInputAdapter
|
13
|
-
from csp.impl.struct import Struct
|
14
12
|
from csp.impl.types.tstype import ts
|
15
13
|
from csp.impl.wiring import py_output_adapter_def, py_push_adapter_def
|
16
|
-
|
17
14
|
from slack_sdk.errors import SlackApiError
|
18
15
|
from slack_sdk.socket_mode import SocketModeClient
|
19
16
|
from slack_sdk.socket_mode.request import SocketModeRequest
|
20
17
|
from slack_sdk.socket_mode.response import SocketModeResponse
|
21
18
|
from slack_sdk.web import WebClient
|
22
19
|
|
20
|
+
from .adapter_config import SlackAdapterConfig
|
21
|
+
from .message import SlackMessage
|
22
|
+
|
23
23
|
T = TypeVar("T")
|
24
24
|
log = getLogger(__file__)
|
25
25
|
|
26
26
|
|
27
|
-
__all__ = ("
|
28
|
-
|
29
|
-
|
30
|
-
class SlackMessage(Struct):
|
31
|
-
user: str
|
32
|
-
user_email: str # email of the author
|
33
|
-
user_id: str # user id of the author
|
34
|
-
tags: List[str] # list of mentions
|
35
|
-
|
36
|
-
channel: str # name of channel
|
37
|
-
channel_id: str # id of channel
|
38
|
-
channel_type: str # type of channel, in "message", "public" (app_mention), "private" (app_mention)
|
39
|
-
|
40
|
-
msg: str # parsed text payload
|
41
|
-
reaction: str # emoji reacts
|
42
|
-
thread: str # thread id, if in thread
|
43
|
-
payload: dict # raw message payload
|
44
|
-
|
45
|
-
|
46
|
-
def mention_user(userid: str) -> str:
|
47
|
-
"""Convenience method, more difficult to do in symphony but we want slack to be symmetric"""
|
48
|
-
return f"<@{userid}>"
|
27
|
+
__all__ = ("SlackAdapterManager", "SlackInputAdapterImpl", "SlackOutputAdapterImpl")
|
49
28
|
|
50
29
|
|
51
30
|
class SlackAdapterManager(AdapterManagerImpl):
|
52
|
-
def __init__(self,
|
53
|
-
if not app_token.startswith("xapp-") or not bot_token.startswith("xoxb-"):
|
54
|
-
raise RuntimeError("Slack app token or bot token looks malformed")
|
55
|
-
|
31
|
+
def __init__(self, config: SlackAdapterConfig):
|
56
32
|
self._slack_client = SocketModeClient(
|
57
|
-
app_token=app_token,
|
58
|
-
web_client=WebClient(token=bot_token, ssl=ssl),
|
33
|
+
app_token=config.app_token,
|
34
|
+
web_client=WebClient(token=config.bot_token, ssl=config.ssl),
|
59
35
|
)
|
60
36
|
self._slack_client.socket_mode_request_listeners.append(self._process_slack_message)
|
61
37
|
|
@@ -72,14 +48,18 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
72
48
|
self._thread: Thread = None
|
73
49
|
|
74
50
|
# lookups for mentions and redirection
|
75
|
-
self.
|
76
|
-
self.
|
77
|
-
self.
|
51
|
+
self._channel_id_to_channel_name: Dict[str, str] = {}
|
52
|
+
self._channel_id_to_channel_type: Dict[str, str] = {}
|
53
|
+
self._channel_name_to_channel_id: Dict[str, str] = {}
|
78
54
|
self._user_id_to_user_name: Dict[str, str] = {}
|
79
55
|
self._user_id_to_user_email: Dict[str, str] = {}
|
80
56
|
self._user_name_to_user_id: Dict[str, str] = {}
|
81
57
|
self._user_email_to_user_id: Dict[str, str] = {}
|
82
58
|
|
59
|
+
# if subscribed to mentions AND events, will get 2 copies,
|
60
|
+
# so we want to dedupe by id
|
61
|
+
self._seen_msg_ids = set()
|
62
|
+
|
83
63
|
def subscribe(self):
|
84
64
|
return _slack_input_adapter(self, push_mode=csp.PushMode.NON_COLLAPSING)
|
85
65
|
|
@@ -121,7 +101,7 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
121
101
|
if ret.status_code == 200:
|
122
102
|
# TODO OAuth scopes required
|
123
103
|
name = ret.data["user"]["profile"].get("real_name_normalized", ret.data["user"]["name"])
|
124
|
-
email = ret.data["user"]["profile"]
|
104
|
+
email = ret.data["user"]["profile"].get("email", "")
|
125
105
|
self._user_id_to_user_name[user_id] = name
|
126
106
|
self._user_name_to_user_id[name] = user_id # TODO is this 1-1 in slack?
|
127
107
|
self._user_id_to_user_email[user_id] = email
|
@@ -140,14 +120,33 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
140
120
|
if ret.status_code == 200:
|
141
121
|
# TODO OAuth scopes required
|
142
122
|
for user in ret.data["members"]:
|
123
|
+
# Grab name
|
143
124
|
name = user["profile"].get("real_name_normalized", user["name"])
|
144
|
-
|
145
|
-
|
125
|
+
|
126
|
+
# Try to grab id
|
127
|
+
if "id" in user:
|
128
|
+
user_id = user["id"]
|
129
|
+
elif "id" in user["profile"]:
|
130
|
+
user_id = user["profile"]["id"]
|
131
|
+
else:
|
132
|
+
raise RuntimeError(f"No id found in user profile: {user}")
|
133
|
+
|
134
|
+
# Try to grab email
|
135
|
+
if "email" in user["profile"]:
|
136
|
+
email = user["profile"]["email"]
|
137
|
+
else:
|
138
|
+
log.warning(f"No email found in user profile, using id: {user}")
|
139
|
+
email = user_id
|
140
|
+
|
146
141
|
self._user_id_to_user_name[user_id] = name
|
147
142
|
self._user_name_to_user_id[name] = user_id # TODO is this 1-1 in slack?
|
148
143
|
self._user_id_to_user_email[user_id] = email
|
149
144
|
self._user_email_to_user_id[email] = user_id
|
150
|
-
|
145
|
+
|
146
|
+
user_id = self._user_name_to_user_id.get(user_name, None)
|
147
|
+
if user_id is None:
|
148
|
+
# no user found
|
149
|
+
raise ValueError(f"User {user_name} not found in Slack")
|
151
150
|
return user_id
|
152
151
|
|
153
152
|
def _channel_data_to_channel_kind(self, data) -> str:
|
@@ -159,8 +158,8 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
159
158
|
|
160
159
|
def _get_channel_from_id(self, channel_id):
|
161
160
|
# try to pull from cache
|
162
|
-
name = self.
|
163
|
-
kind = self.
|
161
|
+
name = self._channel_id_to_channel_name.get(channel_id, None)
|
162
|
+
kind = self._channel_id_to_channel_type.get(channel_id, None)
|
164
163
|
|
165
164
|
# if none, refresh data via web client
|
166
165
|
if name is None:
|
@@ -170,18 +169,29 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
170
169
|
kind = self._channel_data_to_channel_kind(ret.data["channel"])
|
171
170
|
if kind == "message":
|
172
171
|
# TODO use same behavior as symphony adapter
|
173
|
-
name = "
|
172
|
+
name = "IM"
|
174
173
|
else:
|
175
174
|
name = ret.data["channel"]["name"]
|
176
175
|
|
177
|
-
|
178
|
-
|
179
|
-
|
176
|
+
if name == "IM":
|
177
|
+
# store by the name of the user
|
178
|
+
user = ret.data["channel"]["user"]
|
179
|
+
user_name = self._get_user_from_id(user)[0]
|
180
|
+
self._channel_name_to_channel_id[user_name] = channel_id
|
181
|
+
else:
|
182
|
+
self._channel_name_to_channel_id[name] = channel_id
|
183
|
+
self._channel_id_to_channel_name[channel_id] = name
|
184
|
+
self._channel_id_to_channel_type[channel_id] = kind
|
180
185
|
return name, kind
|
181
186
|
|
182
187
|
def _get_channel_from_name(self, channel_name):
|
183
|
-
#
|
184
|
-
|
188
|
+
# first, see if its a regular name or tagged name
|
189
|
+
if channel_name.startswith("<#") and channel_name.endswith("|>"):
|
190
|
+
# strip out the tag
|
191
|
+
channel_id = channel_name[2:-2]
|
192
|
+
else:
|
193
|
+
# try to pull from cache
|
194
|
+
channel_id = self._channel_name_to_channel_id.get(channel_name, None)
|
185
195
|
|
186
196
|
# if none, refresh data via web client
|
187
197
|
if channel_id is None:
|
@@ -194,20 +204,17 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
194
204
|
name = channel["name"]
|
195
205
|
channel_id = channel["id"]
|
196
206
|
kind = self._channel_data_to_channel_kind(channel)
|
197
|
-
self.
|
198
|
-
self.
|
199
|
-
self.
|
200
|
-
|
207
|
+
self._channel_id_to_channel_name[channel_id] = name
|
208
|
+
self._channel_name_to_channel_id[name] = channel_id
|
209
|
+
self._channel_id_to_channel_type[channel_id] = kind
|
210
|
+
channel_id = self._channel_name_to_channel_id.get(channel_name, None)
|
211
|
+
if channel_id is None:
|
212
|
+
# no channel found
|
213
|
+
raise ValueError(f"Channel {channel_name} not found in Slack")
|
201
214
|
return channel_id
|
202
215
|
|
203
|
-
def _get_tags_from_message(self, blocks
|
216
|
+
def _get_tags_from_message(self, blocks) -> List[str]:
|
204
217
|
"""extract tags from message, potentially excluding the bot's own @"""
|
205
|
-
authorizations = authorizations or []
|
206
|
-
if len(authorizations) > 0:
|
207
|
-
bot_id = authorizations[0]["user_id"] # TODO more than one?
|
208
|
-
else:
|
209
|
-
bot_id = ""
|
210
|
-
|
211
218
|
tags = []
|
212
219
|
to_search = blocks.copy()
|
213
220
|
|
@@ -219,11 +226,9 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
219
226
|
|
220
227
|
if element.get("type", "") == "user":
|
221
228
|
tag_id = element.get("user_id")
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
if name:
|
226
|
-
tags.append(name)
|
229
|
+
name, _ = self._get_user_from_id(tag_id)
|
230
|
+
if name:
|
231
|
+
tags.append(name)
|
227
232
|
return tags
|
228
233
|
|
229
234
|
def _process_slack_message(self, client: SocketModeClient, req: SocketModeRequest):
|
@@ -233,10 +238,19 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
233
238
|
response = SocketModeResponse(envelope_id=req.envelope_id)
|
234
239
|
client.send_socket_mode_response(response)
|
235
240
|
|
241
|
+
if req.payload["event"]["ts"] in self._seen_msg_ids:
|
242
|
+
# already seen, pop it and move on
|
243
|
+
self._seen_msg_ids.remove(req.payload["event"]["ts"])
|
244
|
+
return
|
245
|
+
|
246
|
+
# else add it so we don't process it again
|
247
|
+
self._seen_msg_ids.add(req.payload["event"]["ts"])
|
248
|
+
|
236
249
|
if req.payload["event"]["type"] in ("message", "app_mention") and req.payload["event"].get("subtype") is None:
|
237
250
|
user, user_email = self._get_user_from_id(req.payload["event"]["user"])
|
238
251
|
channel, channel_type = self._get_channel_from_id(req.payload["event"]["channel"])
|
239
|
-
|
252
|
+
|
253
|
+
tags = self._get_tags_from_message(req.payload["event"]["blocks"])
|
240
254
|
slack_msg = SlackMessage(
|
241
255
|
user=user or "",
|
242
256
|
user_email=user_email or "",
|
@@ -265,8 +279,8 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
265
279
|
# grab channel or DM
|
266
280
|
if hasattr(slack_msg, "channel_id") and slack_msg.channel_id:
|
267
281
|
channel_id = slack_msg.channel_id
|
282
|
+
|
268
283
|
elif hasattr(slack_msg, "channel") and slack_msg.channel:
|
269
|
-
# TODO DM
|
270
284
|
channel_id = self._get_channel_from_name(slack_msg.channel)
|
271
285
|
|
272
286
|
# pull text or reaction
|
@@ -277,6 +291,7 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
277
291
|
name=slack_msg.reaction,
|
278
292
|
timestamp=slack_msg.thread,
|
279
293
|
)
|
294
|
+
|
280
295
|
elif hasattr(slack_msg, "msg") and slack_msg.msg:
|
281
296
|
try:
|
282
297
|
# send text to channel
|
@@ -285,11 +300,10 @@ class SlackAdapterManager(AdapterManagerImpl):
|
|
285
300
|
text=getattr(slack_msg, "msg", ""),
|
286
301
|
)
|
287
302
|
except SlackApiError:
|
288
|
-
|
289
|
-
...
|
303
|
+
log.exception("Failed to send message to Slack")
|
290
304
|
else:
|
291
305
|
# cannot send empty message, log an error
|
292
|
-
log.
|
306
|
+
log.exception(f"Received malformed SlackMessage instance: {slack_msg}")
|
293
307
|
|
294
308
|
if not self._inqueue.empty():
|
295
309
|
# pull all SlackMessages from queue
|
@@ -0,0 +1,37 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from ssl import SSLContext
|
3
|
+
from typing import Optional
|
4
|
+
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
6
|
+
|
7
|
+
__all__ = ("SlackAdapterConfig",)
|
8
|
+
|
9
|
+
|
10
|
+
class SlackAdapterConfig(BaseModel):
|
11
|
+
"""A config class that holds the required information to interact with Slack."""
|
12
|
+
|
13
|
+
app_token: str = Field(description="The app token for the Slack bot")
|
14
|
+
bot_token: str = Field(description="The bot token for the Slack bot")
|
15
|
+
ssl: Optional[object] = None
|
16
|
+
|
17
|
+
@field_validator("app_token")
|
18
|
+
def validate_app_token(cls, v):
|
19
|
+
if v.startswith("xapp-"):
|
20
|
+
return v
|
21
|
+
elif Path(v).exists():
|
22
|
+
return Path(v).read_text().strip()
|
23
|
+
raise ValueError("App token must start with 'xoxb-' or be a file path")
|
24
|
+
|
25
|
+
@field_validator("bot_token")
|
26
|
+
def validate_bot_token(cls, v):
|
27
|
+
if v.startswith("xoxb-"):
|
28
|
+
return v
|
29
|
+
elif Path(v).exists():
|
30
|
+
return Path(v).read_text().strip()
|
31
|
+
raise ValueError("Bot token must start with 'xoxb-' or be a file path")
|
32
|
+
|
33
|
+
@field_validator("ssl")
|
34
|
+
def validate_ssl(cls, v):
|
35
|
+
# TODO pydantic validation via schema
|
36
|
+
assert v is None or isinstance(v, SSLContext)
|
37
|
+
return v
|
File without changes
|
@@ -0,0 +1,44 @@
|
|
1
|
+
import csp
|
2
|
+
from csp import ts
|
3
|
+
|
4
|
+
from csp_adapter_slack import SlackAdapterConfig, SlackAdapterManager, SlackMessage
|
5
|
+
|
6
|
+
config = SlackAdapterConfig(
|
7
|
+
app_token=".app_token",
|
8
|
+
bot_token=".bot_token",
|
9
|
+
)
|
10
|
+
|
11
|
+
|
12
|
+
@csp.node
|
13
|
+
def add_reaction_when_mentioned(msg: ts[SlackMessage]) -> ts[SlackMessage]:
|
14
|
+
"""Add a reaction to every message that starts with hello."""
|
15
|
+
if msg.msg.lower().startswith("hello"):
|
16
|
+
return SlackMessage(
|
17
|
+
channel=msg.channel,
|
18
|
+
thread=msg.thread,
|
19
|
+
reaction="wave",
|
20
|
+
)
|
21
|
+
|
22
|
+
|
23
|
+
def graph():
|
24
|
+
# Create a DiscordAdapter object
|
25
|
+
adapter = SlackAdapterManager(config)
|
26
|
+
|
27
|
+
# Subscribe and unroll the messages
|
28
|
+
msgs = csp.unroll(adapter.subscribe())
|
29
|
+
|
30
|
+
# Print it out locally for debugging
|
31
|
+
csp.print("msgs", msgs)
|
32
|
+
|
33
|
+
# Add the reaction node
|
34
|
+
reactions = add_reaction_when_mentioned(msgs)
|
35
|
+
|
36
|
+
# Print it out locally for debugging
|
37
|
+
csp.print("reactions", reactions)
|
38
|
+
|
39
|
+
# Publish the reactions
|
40
|
+
adapter.publish(reactions)
|
41
|
+
|
42
|
+
|
43
|
+
if __name__ == "__main__":
|
44
|
+
csp.run(graph, realtime=True)
|
@@ -0,0 +1,10 @@
|
|
1
|
+
__all__ = ("mention_user",)
|
2
|
+
|
3
|
+
|
4
|
+
def mention_user(userid: str) -> str:
|
5
|
+
"""Convenience method, more difficult to do in symphony but we want slack to be symmetric"""
|
6
|
+
if userid.startswith("<@") and userid.endswith(">"):
|
7
|
+
return userid
|
8
|
+
if userid.startswith("@"):
|
9
|
+
return f"<{userid}>"
|
10
|
+
return f"<@{userid}>"
|
@@ -0,0 +1,40 @@
|
|
1
|
+
from typing import List
|
2
|
+
|
3
|
+
from csp.impl.struct import Struct
|
4
|
+
|
5
|
+
__all__ = ("SlackMessage",)
|
6
|
+
|
7
|
+
|
8
|
+
class SlackMessage(Struct):
|
9
|
+
user: str
|
10
|
+
"""name of the author of the message"""
|
11
|
+
|
12
|
+
user_email: str
|
13
|
+
"""email of the author of the message, if available"""
|
14
|
+
|
15
|
+
user_id: str
|
16
|
+
"""platform-specific id of the author of the message, if available"""
|
17
|
+
|
18
|
+
tags: List[str]
|
19
|
+
"""list of users tagged in the `msg` of the message"""
|
20
|
+
|
21
|
+
channel: str
|
22
|
+
"""name of the channel for the slack message, if available"""
|
23
|
+
|
24
|
+
channel_id: str
|
25
|
+
"""id of the channel for the slack message, if available"""
|
26
|
+
|
27
|
+
channel_type: str
|
28
|
+
"""type of the channel. either "message", "public", or "private" """
|
29
|
+
|
30
|
+
msg: str
|
31
|
+
"""parsed text of the message"""
|
32
|
+
|
33
|
+
reaction: str
|
34
|
+
"""emoji reaction to put on a thread. Exclusive with `msg`, requires `thread`"""
|
35
|
+
|
36
|
+
thread: str
|
37
|
+
"""thread to post msg under, or msg id on which to apply reaction"""
|
38
|
+
|
39
|
+
payload: dict
|
40
|
+
"""raw slack message payload"""
|
@@ -1,11 +1,13 @@
|
|
1
|
-
import pytest
|
2
1
|
from datetime import timedelta
|
3
2
|
from ssl import create_default_context
|
4
3
|
from unittest.mock import MagicMock, call, patch
|
5
4
|
|
6
5
|
import csp
|
6
|
+
import pytest
|
7
7
|
from csp import ts
|
8
|
-
from
|
8
|
+
from pydantic import ValidationError
|
9
|
+
|
10
|
+
from csp_adapter_slack import SlackAdapterConfig, SlackAdapterManager, SlackMessage, mention_user
|
9
11
|
|
10
12
|
|
11
13
|
@csp.node
|
@@ -119,8 +121,10 @@ DIRECT_MESSAGE_PAYLOAD = {
|
|
119
121
|
|
120
122
|
class TestSlack:
|
121
123
|
def test_slack_tokens(self):
|
122
|
-
with pytest.raises(
|
123
|
-
|
124
|
+
with pytest.raises(ValidationError):
|
125
|
+
SlackAdapterConfig(app_token="abc", bot_token="xoxb-def")
|
126
|
+
with pytest.raises(ValidationError):
|
127
|
+
SlackAdapterConfig(app_token="xapp-abc", bot_token="def")
|
124
128
|
|
125
129
|
@pytest.mark.parametrize("payload", (PUBLIC_CHANNEL_MENTION_PAYLOAD, DIRECT_MESSAGE_PAYLOAD))
|
126
130
|
def test_slack(self, payload):
|
@@ -130,15 +134,15 @@ class TestSlack:
|
|
130
134
|
reqmock.type = "events_api"
|
131
135
|
reqmock.payload = payload
|
132
136
|
|
133
|
-
# mock out the user/
|
137
|
+
# mock out the user/channel lookup responses
|
134
138
|
mock_user_response = MagicMock(name="users_info_mock")
|
135
139
|
mock_user_response.status_code = 200
|
136
140
|
mock_user_response.data = {"user": {"profile": {"real_name_normalized": "johndoe", "email": "johndoe@some.email"}, "name": "blerg"}}
|
137
141
|
clientmock.return_value.web_client.users_info.return_value = mock_user_response
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
clientmock.return_value.web_client.conversations_info.return_value =
|
142
|
+
mock_channel_response = MagicMock(name="conversations_info_mock")
|
143
|
+
mock_channel_response.status_code = 200
|
144
|
+
mock_channel_response.data = {"channel": {"is_im": False, "is_private": True, "name": "a private channel"}}
|
145
|
+
clientmock.return_value.web_client.conversations_info.return_value = mock_channel_response
|
142
146
|
mock_list_response = MagicMock(name="conversations_list_mock")
|
143
147
|
mock_list_response.status_code = 200
|
144
148
|
mock_list_response.data = {
|
@@ -150,7 +154,7 @@ class TestSlack:
|
|
150
154
|
clientmock.return_value.web_client.conversations_list.return_value = mock_list_response
|
151
155
|
|
152
156
|
def graph():
|
153
|
-
am = SlackAdapterManager("xapp-1-dummy", "xoxb-dummy", ssl=create_default_context())
|
157
|
+
am = SlackAdapterManager(SlackAdapterConfig(app_token="xapp-1-dummy", bot_token="xoxb-dummy", ssl=create_default_context()))
|
154
158
|
|
155
159
|
# send a fake slack message to the app
|
156
160
|
stop = send_fake_message(clientmock, reqmock, am)
|
@@ -189,7 +193,7 @@ class TestSlack:
|
|
189
193
|
|
190
194
|
# check all inbound mocks got called
|
191
195
|
if payload == PUBLIC_CHANNEL_MENTION_PAYLOAD:
|
192
|
-
assert clientmock.return_value.web_client.users_info.call_count ==
|
196
|
+
assert clientmock.return_value.web_client.users_info.call_count == 3
|
193
197
|
else:
|
194
198
|
assert clientmock.return_value.web_client.users_info.call_count == 1
|
195
199
|
assert clientmock.return_value.web_client.conversations_info.call_count == 1
|
@@ -1,17 +1,17 @@
|
|
1
1
|
[build-system]
|
2
2
|
requires = [
|
3
|
-
"hatchling>=1.22.4,<1.
|
4
|
-
"pkginfo>=1.10,<1.
|
3
|
+
"hatchling>=1.22.4,<1.28",
|
4
|
+
"pkginfo>=1.10,<1.13",
|
5
5
|
]
|
6
6
|
build-backend = "hatchling.build"
|
7
7
|
|
8
8
|
[project]
|
9
9
|
name = "csp_adapter_slack"
|
10
10
|
description = "A csp adapter for slack"
|
11
|
-
version = "0.
|
11
|
+
version = "0.3.0"
|
12
12
|
readme = "README.md"
|
13
13
|
license = { file = "LICENSE" }
|
14
|
-
requires-python = ">=3.
|
14
|
+
requires-python = ">=3.9"
|
15
15
|
authors = [{name = "the csp authors", email = "CSPOpenSource@point72.com"}]
|
16
16
|
keywords = [
|
17
17
|
"csp",
|
@@ -25,27 +25,29 @@ classifiers = [
|
|
25
25
|
"Framework :: Jupyter",
|
26
26
|
"Programming Language :: Python",
|
27
27
|
"Programming Language :: Python :: 3",
|
28
|
-
"Programming Language :: Python :: 3.8",
|
29
28
|
"Programming Language :: Python :: 3.9",
|
30
29
|
"Programming Language :: Python :: 3.10",
|
31
30
|
"Programming Language :: Python :: 3.11",
|
32
31
|
"Programming Language :: Python :: 3.12",
|
32
|
+
"Programming Language :: Python :: 3.13",
|
33
33
|
"License :: OSI Approved :: Apache Software License",
|
34
34
|
]
|
35
35
|
dependencies = [
|
36
36
|
"csp",
|
37
|
+
"pydantic>=2",
|
37
38
|
"slack-sdk>=3",
|
38
39
|
]
|
39
40
|
|
40
41
|
[project.optional-dependencies]
|
41
42
|
develop = [
|
42
|
-
"
|
43
|
+
"bump-my-version",
|
43
44
|
"check-manifest",
|
44
|
-
"codespell>=2.2.6,<2.
|
45
|
+
"codespell>=2.2.6,<2.5",
|
45
46
|
"hatchling",
|
46
47
|
"mdformat>=0.7.17,<0.8",
|
47
|
-
"
|
48
|
-
"
|
48
|
+
"mdformat-tables>=1,<1.1",
|
49
|
+
"ruff>=0.5,<0.12",
|
50
|
+
"twine>=5,<7",
|
49
51
|
# test
|
50
52
|
"pytest",
|
51
53
|
"pytest-cov",
|
@@ -59,9 +61,38 @@ test = [
|
|
59
61
|
Repository = "https://github.com/point72/csp-adapter-slack"
|
60
62
|
Homepage = "https://github.com/point72/csp-adapter-slack"
|
61
63
|
|
64
|
+
[tool.bumpversion]
|
65
|
+
current_version = "0.3.0"
|
66
|
+
commit = true
|
67
|
+
tag = false
|
68
|
+
commit_args = "-s"
|
69
|
+
|
70
|
+
[[tool.bumpversion.files]]
|
71
|
+
filename = "csp_adapter_slack/__init__.py"
|
72
|
+
search = '__version__ = "{current_version}"'
|
73
|
+
replace = '__version__ = "{new_version}"'
|
74
|
+
|
75
|
+
[[tool.bumpversion.files]]
|
76
|
+
filename = "pyproject.toml"
|
77
|
+
search = 'version = "{current_version}"'
|
78
|
+
replace = 'version = "{new_version}"'
|
79
|
+
|
62
80
|
[tool.check-manifest]
|
63
81
|
ignore = []
|
64
82
|
|
83
|
+
[tool.coverage.run]
|
84
|
+
branch = true
|
85
|
+
omit = []
|
86
|
+
|
87
|
+
[tool.coverage.report]
|
88
|
+
exclude_also = [
|
89
|
+
"raise NotImplementedError",
|
90
|
+
"if __name__ == .__main__.:",
|
91
|
+
"@(abc\\.)?abstractmethod",
|
92
|
+
]
|
93
|
+
ignore_errors = true
|
94
|
+
fail_under = 75
|
95
|
+
|
65
96
|
[tool.hatch.build]
|
66
97
|
artifacts = []
|
67
98
|
|
@@ -69,11 +100,7 @@ artifacts = []
|
|
69
100
|
src = "/"
|
70
101
|
|
71
102
|
[tool.hatch.build.targets.sdist]
|
72
|
-
|
73
|
-
"/csp_adapter_slack",
|
74
|
-
"LICENSE",
|
75
|
-
"README.md",
|
76
|
-
]
|
103
|
+
packages = ["csp_adapter_slack"]
|
77
104
|
exclude = [
|
78
105
|
"/.github",
|
79
106
|
"/.gitignore",
|
@@ -81,9 +108,7 @@ exclude = [
|
|
81
108
|
]
|
82
109
|
|
83
110
|
[tool.hatch.build.targets.wheel]
|
84
|
-
|
85
|
-
"/csp_adapter_slack",
|
86
|
-
]
|
111
|
+
packages = ["csp_adapter_slack"]
|
87
112
|
exclude = [
|
88
113
|
"/.github",
|
89
114
|
"/.gitignore",
|
@@ -92,12 +117,16 @@ exclude = [
|
|
92
117
|
]
|
93
118
|
|
94
119
|
[tool.pytest.ini_options]
|
120
|
+
addopts = ["-vvv", "--junitxml=junit.xml"]
|
95
121
|
asyncio_mode = "strict"
|
96
122
|
testpaths = "csp_adapter_slack/tests"
|
97
123
|
|
98
124
|
[tool.ruff]
|
99
125
|
line-length = 150
|
100
126
|
|
127
|
+
[tool.ruff.lint]
|
128
|
+
extend-select = ["I"]
|
129
|
+
|
101
130
|
[tool.ruff.lint.per-file-ignores]
|
102
131
|
"__init__.py" = ["F401", "F403"]
|
103
132
|
|
@@ -107,7 +136,8 @@ default-section = "third-party"
|
|
107
136
|
known-first-party = ["csp_adapter_slack"]
|
108
137
|
section-order = [
|
109
138
|
"future",
|
139
|
+
"standard-library",
|
110
140
|
"third-party",
|
111
141
|
"first-party",
|
112
142
|
"local-folder",
|
113
|
-
]
|
143
|
+
]
|
File without changes
|