csp-adapter-slack 0.2.0__py3-none-any.whl → 0.3.0__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.
@@ -1,4 +1,6 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.3.0"
2
2
 
3
3
  from .adapter import *
4
4
  from .adapter_config import *
5
+ from .mention import *
6
+ from .message import *
@@ -9,7 +9,6 @@ import csp
9
9
  from csp.impl.adaptermanager import AdapterManagerImpl
10
10
  from csp.impl.outputadapter import OutputAdapter
11
11
  from csp.impl.pushadapter import PushInputAdapter
12
- from csp.impl.struct import Struct
13
12
  from csp.impl.types.tstype import ts
14
13
  from csp.impl.wiring import py_output_adapter_def, py_push_adapter_def
15
14
  from slack_sdk.errors import SlackApiError
@@ -19,33 +18,13 @@ from slack_sdk.socket_mode.response import SocketModeResponse
19
18
  from slack_sdk.web import WebClient
20
19
 
21
20
  from .adapter_config import SlackAdapterConfig
21
+ from .message import SlackMessage
22
22
 
23
23
  T = TypeVar("T")
24
24
  log = getLogger(__file__)
25
25
 
26
26
 
27
- __all__ = ("SlackMessage", "mention_user", "SlackAdapterManager", "SlackInputAdapterImpl", "SlackOutputAdapterImpl")
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):
@@ -69,14 +48,18 @@ class SlackAdapterManager(AdapterManagerImpl):
69
48
  self._thread: Thread = None
70
49
 
71
50
  # lookups for mentions and redirection
72
- self._room_id_to_room_name: Dict[str, str] = {}
73
- self._room_id_to_room_type: Dict[str, str] = {}
74
- self._room_name_to_room_id: Dict[str, str] = {}
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] = {}
75
54
  self._user_id_to_user_name: Dict[str, str] = {}
76
55
  self._user_id_to_user_email: Dict[str, str] = {}
77
56
  self._user_name_to_user_id: Dict[str, str] = {}
78
57
  self._user_email_to_user_id: Dict[str, str] = {}
79
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
+
80
63
  def subscribe(self):
81
64
  return _slack_input_adapter(self, push_mode=csp.PushMode.NON_COLLAPSING)
82
65
 
@@ -137,14 +120,33 @@ class SlackAdapterManager(AdapterManagerImpl):
137
120
  if ret.status_code == 200:
138
121
  # TODO OAuth scopes required
139
122
  for user in ret.data["members"]:
123
+ # Grab name
140
124
  name = user["profile"].get("real_name_normalized", user["name"])
141
- user_id = user["profile"]["id"]
142
- email = user["profile"]["email"]
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
+
143
141
  self._user_id_to_user_name[user_id] = name
144
142
  self._user_name_to_user_id[name] = user_id # TODO is this 1-1 in slack?
145
143
  self._user_id_to_user_email[user_id] = email
146
144
  self._user_email_to_user_id[email] = user_id
147
- return self._user_name_to_user_id.get(user_name, None)
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")
148
150
  return user_id
149
151
 
150
152
  def _channel_data_to_channel_kind(self, data) -> str:
@@ -156,8 +158,8 @@ class SlackAdapterManager(AdapterManagerImpl):
156
158
 
157
159
  def _get_channel_from_id(self, channel_id):
158
160
  # try to pull from cache
159
- name = self._room_id_to_room_name.get(channel_id, None)
160
- kind = self._room_id_to_room_type.get(channel_id, None)
161
+ name = self._channel_id_to_channel_name.get(channel_id, None)
162
+ kind = self._channel_id_to_channel_type.get(channel_id, None)
161
163
 
162
164
  # if none, refresh data via web client
163
165
  if name is None:
@@ -167,18 +169,29 @@ class SlackAdapterManager(AdapterManagerImpl):
167
169
  kind = self._channel_data_to_channel_kind(ret.data["channel"])
168
170
  if kind == "message":
169
171
  # TODO use same behavior as symphony adapter
170
- name = "DM"
172
+ name = "IM"
171
173
  else:
172
174
  name = ret.data["channel"]["name"]
173
175
 
174
- self._room_id_to_room_name[channel_id] = name
175
- self._room_name_to_room_id[name] = channel_id
176
- self._room_id_to_room_type[channel_id] = kind
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
177
185
  return name, kind
178
186
 
179
187
  def _get_channel_from_name(self, channel_name):
180
- # try to pull from cache
181
- channel_id = self._room_name_to_room_id.get(channel_name, None)
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)
182
195
 
183
196
  # if none, refresh data via web client
184
197
  if channel_id is None:
@@ -191,10 +204,13 @@ class SlackAdapterManager(AdapterManagerImpl):
191
204
  name = channel["name"]
192
205
  channel_id = channel["id"]
193
206
  kind = self._channel_data_to_channel_kind(channel)
194
- self._room_id_to_room_name[channel_id] = name
195
- self._room_name_to_room_id[name] = channel_id
196
- self._room_id_to_room_type[channel_id] = kind
197
- return self._room_name_to_room_id.get(channel_name, None)
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")
198
214
  return channel_id
199
215
 
200
216
  def _get_tags_from_message(self, blocks) -> List[str]:
@@ -222,9 +238,18 @@ class SlackAdapterManager(AdapterManagerImpl):
222
238
  response = SocketModeResponse(envelope_id=req.envelope_id)
223
239
  client.send_socket_mode_response(response)
224
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
+
225
249
  if req.payload["event"]["type"] in ("message", "app_mention") and req.payload["event"].get("subtype") is None:
226
250
  user, user_email = self._get_user_from_id(req.payload["event"]["user"])
227
251
  channel, channel_type = self._get_channel_from_id(req.payload["event"]["channel"])
252
+
228
253
  tags = self._get_tags_from_message(req.payload["event"]["blocks"])
229
254
  slack_msg = SlackMessage(
230
255
  user=user or "",
@@ -254,8 +279,8 @@ class SlackAdapterManager(AdapterManagerImpl):
254
279
  # grab channel or DM
255
280
  if hasattr(slack_msg, "channel_id") and slack_msg.channel_id:
256
281
  channel_id = slack_msg.channel_id
282
+
257
283
  elif hasattr(slack_msg, "channel") and slack_msg.channel:
258
- # TODO DM
259
284
  channel_id = self._get_channel_from_name(slack_msg.channel)
260
285
 
261
286
  # pull text or reaction
@@ -266,6 +291,7 @@ class SlackAdapterManager(AdapterManagerImpl):
266
291
  name=slack_msg.reaction,
267
292
  timestamp=slack_msg.thread,
268
293
  )
294
+
269
295
  elif hasattr(slack_msg, "msg") and slack_msg.msg:
270
296
  try:
271
297
  # send text to channel
@@ -274,11 +300,10 @@ class SlackAdapterManager(AdapterManagerImpl):
274
300
  text=getattr(slack_msg, "msg", ""),
275
301
  )
276
302
  except SlackApiError:
277
- # TODO
278
- ...
303
+ log.exception("Failed to send message to Slack")
279
304
  else:
280
305
  # cannot send empty message, log an error
281
- log.error(f"Received malformed SlackMessage instance: {slack_msg}")
306
+ log.exception(f"Received malformed SlackMessage instance: {slack_msg}")
282
307
 
283
308
  if not self._inqueue.empty():
284
309
  # pull all SlackMessages from queue
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"""
@@ -134,15 +134,15 @@ class TestSlack:
134
134
  reqmock.type = "events_api"
135
135
  reqmock.payload = payload
136
136
 
137
- # mock out the user/room lookup responses
137
+ # mock out the user/channel lookup responses
138
138
  mock_user_response = MagicMock(name="users_info_mock")
139
139
  mock_user_response.status_code = 200
140
140
  mock_user_response.data = {"user": {"profile": {"real_name_normalized": "johndoe", "email": "johndoe@some.email"}, "name": "blerg"}}
141
141
  clientmock.return_value.web_client.users_info.return_value = mock_user_response
142
- mock_room_response = MagicMock(name="conversations_info_mock")
143
- mock_room_response.status_code = 200
144
- mock_room_response.data = {"channel": {"is_im": False, "is_private": True, "name": "a private channel"}}
145
- clientmock.return_value.web_client.conversations_info.return_value = mock_room_response
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
146
146
  mock_list_response = MagicMock(name="conversations_list_mock")
147
147
  mock_list_response.status_code = 200
148
148
  mock_list_response.data = {
@@ -193,7 +193,7 @@ class TestSlack:
193
193
 
194
194
  # check all inbound mocks got called
195
195
  if payload == PUBLIC_CHANNEL_MENTION_PAYLOAD:
196
- assert clientmock.return_value.web_client.users_info.call_count == 2
196
+ assert clientmock.return_value.web_client.users_info.call_count == 3
197
197
  else:
198
198
  assert clientmock.return_value.web_client.users_info.call_count == 1
199
199
  assert clientmock.return_value.web_client.conversations_info.call_count == 1
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: csp_adapter_slack
3
- Version: 0.2.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
@@ -206,32 +206,32 @@ License: Apache License
206
206
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
207
207
  See the License for the specific language governing permissions and
208
208
  limitations under the License.
209
+ License-File: LICENSE
209
210
  Keywords: chat,chatbot,csp,slack,stream-processing
210
211
  Classifier: Development Status :: 4 - Beta
211
212
  Classifier: Framework :: Jupyter
212
213
  Classifier: License :: OSI Approved :: Apache Software License
213
214
  Classifier: Programming Language :: Python
214
215
  Classifier: Programming Language :: Python :: 3
215
- Classifier: Programming Language :: Python :: 3.8
216
216
  Classifier: Programming Language :: Python :: 3.9
217
217
  Classifier: Programming Language :: Python :: 3.10
218
218
  Classifier: Programming Language :: Python :: 3.11
219
219
  Classifier: Programming Language :: Python :: 3.12
220
220
  Classifier: Programming Language :: Python :: 3.13
221
- Requires-Python: >=3.8
221
+ Requires-Python: >=3.9
222
222
  Requires-Dist: csp
223
223
  Requires-Dist: pydantic>=2
224
224
  Requires-Dist: slack-sdk>=3
225
225
  Provides-Extra: develop
226
226
  Requires-Dist: bump-my-version; extra == 'develop'
227
227
  Requires-Dist: check-manifest; extra == 'develop'
228
- Requires-Dist: codespell<2.4,>=2.2.6; extra == 'develop'
228
+ Requires-Dist: codespell<2.5,>=2.2.6; extra == 'develop'
229
229
  Requires-Dist: hatchling; extra == 'develop'
230
230
  Requires-Dist: mdformat-tables<1.1,>=1; extra == 'develop'
231
231
  Requires-Dist: mdformat<0.8,>=0.7.17; extra == 'develop'
232
232
  Requires-Dist: pytest; extra == 'develop'
233
233
  Requires-Dist: pytest-cov; extra == 'develop'
234
- Requires-Dist: ruff<0.9,>=0.5; extra == 'develop'
234
+ Requires-Dist: ruff<0.12,>=0.5; extra == 'develop'
235
235
  Requires-Dist: twine<7,>=5; extra == 'develop'
236
236
  Provides-Extra: test
237
237
  Requires-Dist: pytest; extra == 'test'
@@ -249,8 +249,14 @@ A [csp](https://github.com/point72/csp) adapter for [slack](https://slack.com)
249
249
 
250
250
  ## Features
251
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
+
252
254
  [More information is available in our wiki](https://github.com/Point72/csp-adapter-slack/wiki)
253
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
+
254
260
  ## Installation
255
261
 
256
262
  Install with `pip`:
@@ -267,4 +273,4 @@ conda install csp csp-adapter-slack -c conda-forge
267
273
 
268
274
  ## License
269
275
 
270
- 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.
@@ -0,0 +1,12 @@
1
+ csp_adapter_slack/__init__.py,sha256=Nt8hM5Q1Kb7-X7xKfVei0AQCAV6UWBdU41lTXp0qylw,122
2
+ csp_adapter_slack/adapter.py,sha256=bAs6cTE-ifH-uzheYG9BqkcKbWmfWlt1Txaih60hehM,14890
3
+ csp_adapter_slack/adapter_config.py,sha256=ltLdb9ESSz3O95xCfxKHMNoxDAHYjo07r4DwgGs4SYc,1221
4
+ csp_adapter_slack/mention.py,sha256=q8xTDY6ETP-yvhNheUSqEsrzXXLUH-WL6WBESaohvDM,330
5
+ csp_adapter_slack/message.py,sha256=UUa1ZPj-d-_HyXHt-Sv7S96LsdFc1Zp7QtVT-yUOcg0,994
6
+ csp_adapter_slack/examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ csp_adapter_slack/examples/hello.py,sha256=1lvSqtI59CkFnW2PwxxkHo242ivMcDBU8YV8w0BDocQ,1063
8
+ csp_adapter_slack/tests/test_adapter.py,sha256=a-Sjr_jtUUWlI4eNB9Z8x7nF0wco3-7Yt8mLTW2XB0Y,8539
9
+ csp_adapter_slack-0.3.0.dist-info/METADATA,sha256=qdCdNeMXW6Lm3k3yN2g05Jjdu_DTSnaMOpFPCW62lJs,16009
10
+ csp_adapter_slack-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ csp_adapter_slack-0.3.0.dist-info/licenses/LICENSE,sha256=Dz904Ba4BFTsnLKU2nrCbcc8nqE1hZvaVDl_oOPueDE,11344
12
+ csp_adapter_slack-0.3.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.26.3
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,8 +0,0 @@
1
- csp_adapter_slack/__init__.py,sha256=ra0C5usflKAD5tO-ck7bd1BdChQuex6Y7UsXC9sXSks,76
2
- csp_adapter_slack/adapter.py,sha256=21KnGE7eP68sGB63Fy3atG1BLRxIjxDRgQg6cW0dUhc,13650
3
- csp_adapter_slack/adapter_config.py,sha256=ltLdb9ESSz3O95xCfxKHMNoxDAHYjo07r4DwgGs4SYc,1221
4
- csp_adapter_slack/tests/test_adapter.py,sha256=OczVSohe9U4kp0OS9rEjUb9wvOgmcoswY4gGBSuSqpo,8524
5
- csp_adapter_slack-0.2.0.dist-info/METADATA,sha256=X63AjcpUqwxqsUccB3zkqlRBsAkvtupksZs9itCI6oY,15658
6
- csp_adapter_slack-0.2.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
7
- csp_adapter_slack-0.2.0.dist-info/licenses/LICENSE,sha256=Dz904Ba4BFTsnLKU2nrCbcc8nqE1hZvaVDl_oOPueDE,11344
8
- csp_adapter_slack-0.2.0.dist-info/RECORD,,