diff --git a/reconcile/utils/slack_api.py b/reconcile/utils/slack_api.py index 53b148cd5f..d7840cdcb1 100644 --- a/reconcile/utils/slack_api.py +++ b/reconcile/utils/slack_api.py @@ -13,6 +13,7 @@ Protocol, Union, ) +from urllib.parse import urlparse from slack_sdk import WebClient from slack_sdk.errors import SlackApiError @@ -224,7 +225,12 @@ def _configure_client_retry(self) -> None: self._sc.retry_handlers.append(rate_limit_handler) self._sc.retry_handlers.append(server_error_handler) - def chat_post_message(self, text: str) -> None: + def chat_post_message( + self, + text: str, + channel_override: Optional[str] = None, + thread_ts: Optional[str] = None, + ) -> None: """ Try to send a chat message into a channel. If the bot is not in the channel it will join the channel and send the message again. @@ -234,22 +240,25 @@ def chat_post_message(self, text: str) -> None: :raises slack_sdk.errors.SlackApiError: if unsuccessful response from Slack API, except for not_in_channel """ - if not self.channel: + channel = channel_override or self.channel + if not channel: raise ValueError( "Slack channel name must be provided when posting messages." ) def do_send(c: str, t: str) -> None: slack_request.labels("chat.postMessage", "POST").inc() - self._sc.chat_postMessage(channel=c, text=t, **self.chat_kwargs) + self._sc.chat_postMessage( + channel=c, text=t, **self.chat_kwargs, thread_ts=thread_ts + ) try: - do_send(self.channel, text) + do_send(channel, text) except SlackApiError as e: match e.response["error"]: case "not_in_channel": - self.join_channel() - do_send(self.channel, text) + self.join_channel(channel_override=channel_override) + do_send(channel, text) # When a message is sent to #someChannel and the Slack API can't find # it, the message it provides in the exception doesn't include the # channel name. We handle that here in case the consumer has many such @@ -260,6 +269,29 @@ def do_send(c: str, t: str) -> None: case _: raise + def _get_channel_and_timestamp(self, url: str) -> tuple[str, str]: + # example parent message url + # https://example.slack.com/archives/C017E996GPP/p1715146351427019 + parsed_url = urlparse(url) + if parsed_url.netloc != f"{self.workspace_name}.slack.com": + raise ValueError("Slack workspace must match thread URL.") + _, _, channel, p_timestamp = parsed_url.path.split("/") + timestamp = p_timestamp.replace("p", "") + ts = f"{timestamp[:10]}.{timestamp[10:]}" if "." not in timestamp else timestamp + + return channel, ts + + def chat_post_message_to_thread(self, text: str, thread_url: str) -> None: + """ + Send a message to a thread + """ + channel, thread_ts = self._get_channel_and_timestamp(thread_url) + self.chat_post_message(text, channel_override=channel, thread_ts=thread_ts) + + def add_reaction(self, reaction: str, message_url: str) -> None: + channel, message_ts = self._get_channel_and_timestamp(message_url) + self._sc.reactions_add(channel=channel, name=reaction, timestamp=message_ts) + def describe_usergroup( self, handle: str ) -> tuple[dict[str, str], dict[str, str], str]: @@ -274,7 +306,7 @@ def describe_usergroup( return users, channels, description - def join_channel(self) -> None: + def join_channel(self, channel_override: Optional[str] = None) -> None: """ Join a given channel if not already a member, will join self.channel @@ -282,13 +314,14 @@ def join_channel(self) -> None: Slack API :raises ValueError: if self.channel is not set """ - if not self.channel: + channel = channel_override or self.channel + if channel: raise ValueError( "Slack channel name must be provided when joining a channel." ) - channels_found = self.get_channels_by_names(self.channel) - [channel_id] = [k for k in channels_found if channels_found[k] == self.channel] + channels_found = self.get_channels_by_names(channel) + [channel_id] = [k for k in channels_found if channels_found[k] == channel] slack_request.labels("conversations.info", "GET").inc() info = self._sc.conversations_info(channel=channel_id) diff --git a/tools/qontract_cli.py b/tools/qontract_cli.py index ab31f8f5f1..28cc43cbb6 100755 --- a/tools/qontract_cli.py +++ b/tools/qontract_cli.py @@ -2628,6 +2628,48 @@ def slack_usergroup(ctx, workspace, usergroup, username): slack.update_usergroup_users(ugid, users) +@root.group(name="slack") +@output +@click.pass_context +def slack(ctx, output): + ctx.obj["output"] = output + + +@slack.command() +@click.argument("message") +@click.pass_context +def message(ctx, message: str): + """ + Send a Slack message. + """ + slack = slackapi_from_queries("qontract-cli", init_usergroups=False) + slack.chat_post_message(message) + + +@slack.command() +@click.argument("message") +@click.argument("thread_url") +@click.pass_context +def message_thread(ctx, message: str, thread_url: str): + """ + Send a Slack message to a thread. + """ + slack = slackapi_from_queries("qontract-cli", init_usergroups=False) + slack.chat_post_message_to_thread(message, thread_url) + + +@slack.command() +@click.argument("reaction") +@click.argument("message_url") +@click.pass_context +def reaction(ctx, reaction: str, message_url: str): + """ + React with an emoji to a message. + """ + slack = slackapi_from_queries("qontract-cli", init_usergroups=False) + slack.add_reaction(reaction, message_url) + + @set_command.command() @click.argument("org_name") @click.argument("cluster_name")