Skip to content

Commit b3ceb3a

Browse files
committed
sending to channel-id, channel resolving and listing
1 parent ed170b7 commit b3ceb3a

1 file changed

Lines changed: 117 additions & 20 deletions

File tree

elementary/messages/messaging_integrations/slack_web.py

Lines changed: 117 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import json
2+
import re
23
import ssl
34
import 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

69
from ratelimit import limits, sleep_and_retry
710
from slack_sdk import WebClient
@@ -30,6 +33,32 @@
3033
ONE_MINUTE = 60
3134
ONE_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

3463
Channel: 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

Comments
 (0)