11import json
2+ import re
23import ssl
34import time
4- from typing import Any , Dict , Iterator , Optional
5+ from dataclasses import dataclass
6+ from http import HTTPStatus
7+ from typing import Any , Dict , Iterator , List , Optional , Tuple
58
69from ratelimit import limits , sleep_and_retry
710from slack_sdk import WebClient
3033ONE_MINUTE = 60
3134ONE_SECOND = 1
3235
36+ _CHANNEL_ID_PATTERN = re .compile (r"^[CGD][A-Z0-9]{8,}$" )
37+
38+
39+ def _is_channel_id (value : str ) -> bool :
40+ return bool (_CHANNEL_ID_PATTERN .match (value ))
41+
42+
43+ def _normalize_channel_input (raw : str ) -> str :
44+ normalized = raw .strip ()
45+ if normalized .startswith ("#" ):
46+ normalized = normalized [1 :].strip ()
47+ return normalized
48+
49+
50+ @dataclass
51+ class ResolvedChannel :
52+ name : str
53+ id : str
54+
55+
56+ @dataclass
57+ class ChannelsResponse :
58+ channels : list [ResolvedChannel ]
59+ retry_after : int | None
60+ cursor : str | None
61+
3362
3463Channel : TypeAlias = str
3564
@@ -51,6 +80,7 @@ def __init__(
5180 self .client = client
5281 self .tracking = tracking
5382 self ._email_to_user_id_cache : Dict [str , str ] = {}
83+ self ._channel_cache : Dict [Tuple [str , bool ], ResolvedChannel ] = {}
5484 self .reply_broadcast = reply_broadcast
5585
5686 @classmethod
@@ -132,7 +162,7 @@ def _handle_send_err(self, err: SlackApiError, channel_name: str):
132162 logger .info (
133163 f'Elementary app is not in the channel "{ channel_name } ". Attempting to join.'
134164 )
135- channel_id = self ._get_channel_id (channel_name , only_public = True )
165+ channel_id = self .resolve_channel (channel_name , only_public = True ). id
136166 self ._join_channel (channel_id = channel_id )
137167 logger .info (f"Joined channel { channel_name } " )
138168 elif err_type == "channel_not_found" :
@@ -143,6 +173,19 @@ def _handle_send_err(self, err: SlackApiError, channel_name: str):
143173 f"Failed to send a message to channel - { channel_name } "
144174 )
145175
176+ def _list_conversations (
177+ self , cursor : Optional [str ] = None
178+ ) -> Tuple [List [dict ], Optional [str ]]:
179+ response = self .client .conversations_list (
180+ cursor = cursor ,
181+ types = "public_channel,private_channel" ,
182+ exclude_archived = True ,
183+ limit = 1000 ,
184+ )
185+ channels = response .get ("channels" , [])
186+ cursor = response .get ("response_metadata" , {}).get ("next_cursor" )
187+ return channels , cursor
188+
146189 @sleep_and_retry
147190 @limits (calls = 20 , period = ONE_MINUTE )
148191 def _iter_channels (
@@ -155,29 +198,83 @@ def _iter_channels(
155198 raise MessagingIntegrationError ("Channel iteration timed out" )
156199
157200 call_start = time .time ()
158- response = self .client .conversations_list (
159- cursor = cursor ,
160- types = "public_channel" if only_public else "public_channel,private_channel" ,
161- exclude_archived = True ,
162- limit = 1000 ,
163- )
201+ channels , cursor = self ._list_conversations (cursor )
164202 call_duration = time .time () - call_start
165203
166- channels = response ["channels" ]
167204 yield from channels
168- response_metadata = response .get ("response_metadata" ) or {}
169- next_cursor = response_metadata .get ("next_cursor" )
170- if next_cursor :
171- if not isinstance (next_cursor , str ):
172- raise ValueError ("Next cursor is not a string" )
205+ if cursor :
173206 timeout_left = timeout - call_duration
174- yield from self ._iter_channels (next_cursor , only_public , timeout_left )
207+ yield from self ._iter_channels (cursor , only_public , timeout_left )
208+
209+ @sleep_and_retry
210+ @limits (calls = 50 , period = ONE_MINUTE )
211+ def resolve_channel (
212+ self , channel : str , only_public : bool = False
213+ ) -> ResolvedChannel :
214+ normalized = _normalize_channel_input (channel )
215+ cache_key = (normalized , only_public )
216+ if cache_key in self ._channel_cache :
217+ return self ._channel_cache [cache_key ]
218+
219+ if _is_channel_id (normalized ):
220+ try :
221+ response = self .client .conversations_info (channel = normalized )
222+ except SlackApiError as e :
223+ if self .tracking :
224+ self .tracking .record_internal_exception (e )
225+ raise MessagingIntegrationError (
226+ f"Channel { normalized } not found"
227+ ) from e
228+ ch = response ["channel" ]
229+ resolved = ResolvedChannel (name = ch ["name" ], id = ch ["id" ])
230+ else :
231+ for ch in self ._iter_channels (only_public = only_public ):
232+ if ch ["name" ] == normalized :
233+ resolved = ResolvedChannel (name = ch ["name" ], id = ch ["id" ])
234+ break
235+ else :
236+ raise MessagingIntegrationError (f"Channel { normalized } not found" )
237+
238+ self ._channel_cache [cache_key ] = resolved
239+ return resolved
240+
241+ def get_channels (
242+ self ,
243+ cursor : str | None = None ,
244+ timeout_seconds : int = 15 ,
245+ ) -> ChannelsResponse :
246+ channels_response = ChannelsResponse (channels = [], retry_after = None , cursor = None )
247+
248+ start_time = time .time ()
249+ time_elapsed : float = 0
250+ while time_elapsed < timeout_seconds :
251+ try :
252+ channels , cursor = self ._list_conversations (cursor )
253+ time_elapsed = time .time () - start_time
254+ logger .debug (
255+ f"Got a batch of { len (channels )} channels! time elapsed: { time_elapsed } seconds"
256+ )
257+
258+ channels_response .channels .extend (
259+ [
260+ ResolvedChannel (name = chan ["name" ], id = chan ["id" ])
261+ for chan in channels
262+ ]
263+ )
264+
265+ if not cursor :
266+ break
267+
268+ except SlackApiError as err :
269+ if err .response .status_code == HTTPStatus .TOO_MANY_REQUESTS :
270+ channels_response .retry_after = int (
271+ err .response .headers ["Retry-After" ]
272+ )
273+ break
274+ raise
175275
176- def _get_channel_id (self , channel_name : str , only_public : bool = False ) -> str :
177- for channel in self ._iter_channels (only_public = only_public ):
178- if channel ["name" ] == channel_name :
179- return channel ["id" ]
180- raise MessagingIntegrationError (f"Channel { channel_name } not found" )
276+ channels_response .cursor = cursor
277+ return channels_response
181278
182279 def _join_channel (self , channel_id : str ) -> None :
183280 try :
0 commit comments