Skip to content

Commit 5cebee2

Browse files
committed
Experimental slack bot support
Currently just displays release status
1 parent 84d7b62 commit 5cebee2

File tree

4 files changed

+344
-0
lines changed

4 files changed

+344
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies = [
3131
"py_trees>=2.2,<3.0",
3232
"pyyaml>=6.0.3",
3333
"slack-sdk>=3.38.0",
34+
"slack-bolt>=1.27.0",
3435
]
3536

3637
[project.optional-dependencies]

src/redis_release/cli.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,5 +188,58 @@ def status(
188188
printer.update_message(state_syncer.state)
189189

190190

191+
@app.command()
192+
def slack_bot(
193+
config_file: Optional[str] = typer.Option(
194+
None, "--config", "-c", help="Path to config file (default: config.yaml)"
195+
),
196+
slack_bot_token: Optional[str] = typer.Option(
197+
None,
198+
"--slack-bot-token",
199+
help="Slack bot token (xoxb-...). If not provided, uses SLACK_BOT_TOKEN env var",
200+
),
201+
slack_app_token: Optional[str] = typer.Option(
202+
None,
203+
"--slack-app-token",
204+
help="Slack app token (xapp-...). If not provided, uses SLACK_APP_TOKEN env var",
205+
),
206+
reply_in_thread: bool = typer.Option(
207+
True,
208+
"--reply-in-thread/--no-reply-in-thread",
209+
help="Reply in thread instead of main channel",
210+
),
211+
broadcast_to_channel: bool = typer.Option(
212+
False,
213+
"--broadcast/--no-broadcast",
214+
help="When replying in thread, also show in main channel",
215+
),
216+
) -> None:
217+
"""Run Slack bot that listens for status requests.
218+
219+
The bot listens for mentions containing 'status' and a version tag (e.g., '8.4-m01'),
220+
and responds by posting the release status to the channel.
221+
222+
By default, replies are posted in threads to keep channels clean. Use --no-reply-in-thread
223+
to post directly in the channel. Use --broadcast to show thread replies in the main channel.
224+
225+
Requires Socket Mode to be enabled in your Slack app configuration.
226+
"""
227+
from redis_release.slack_bot import run_bot
228+
229+
setup_logging()
230+
config_path = config_file or "config.yaml"
231+
232+
logger.info("Starting Slack bot...")
233+
asyncio.run(
234+
run_bot(
235+
config_path,
236+
slack_bot_token,
237+
slack_app_token,
238+
reply_in_thread,
239+
broadcast_to_channel,
240+
)
241+
)
242+
243+
191244
if __name__ == "__main__":
192245
app()

src/redis_release/slack_bot.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""Async Slack bot that listens for status requests and posts release status."""
2+
3+
import asyncio
4+
import logging
5+
import os
6+
import re
7+
from typing import Optional
8+
9+
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
10+
from slack_bolt.async_app import AsyncApp
11+
12+
from redis_release.config import Config, load_config
13+
from redis_release.models import ReleaseArgs
14+
from redis_release.state_manager import S3StateStorage, StateManager
15+
from redis_release.state_slack import SlackStatePrinter
16+
17+
logger = logging.getLogger(__name__)
18+
19+
# Regex pattern to match version tags like 8.4-m01, 7.2.5, 8.0-rc1, etc.
20+
VERSION_TAG_PATTERN = re.compile(r"\b(\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9]+)?)\b")
21+
22+
23+
class ReleaseStatusBot:
24+
"""Async Slack bot that responds to status requests for releases."""
25+
26+
def __init__(
27+
self,
28+
config: Config,
29+
slack_bot_token: Optional[str] = None,
30+
slack_app_token: Optional[str] = None,
31+
reply_in_thread: bool = True,
32+
broadcast_to_channel: bool = False,
33+
):
34+
"""Initialize the bot.
35+
36+
Args:
37+
config: Release configuration
38+
slack_bot_token: Slack bot token (xoxb-...). If None, uses SLACK_BOT_TOKEN env var
39+
slack_app_token: Slack app token (xapp-...). If None, uses SLACK_APP_TOKEN env var
40+
reply_in_thread: If True, reply in thread. If False, reply in main channel
41+
broadcast_to_channel: If True and reply_in_thread is True, also show in main channel
42+
"""
43+
self.config = config
44+
self.reply_in_thread = reply_in_thread
45+
self.broadcast_to_channel = broadcast_to_channel
46+
47+
# Get tokens from args or environment
48+
bot_token = slack_bot_token or os.environ.get("SLACK_BOT_TOKEN")
49+
app_token = slack_app_token or os.environ.get("SLACK_APP_TOKEN")
50+
51+
if not bot_token:
52+
raise ValueError(
53+
"Slack bot token not provided. Use slack_bot_token argument or set SLACK_BOT_TOKEN environment variable"
54+
)
55+
if not app_token:
56+
raise ValueError(
57+
"Slack app token not provided. Use slack_app_token argument or set SLACK_APP_TOKEN environment variable"
58+
)
59+
60+
# Store validated tokens (guaranteed to be non-None)
61+
self.bot_token: str = bot_token
62+
self.app_token: str = app_token
63+
64+
# Initialize async Slack app
65+
self.app = AsyncApp(token=self.bot_token)
66+
67+
# Register event handlers
68+
self._register_handlers()
69+
70+
def _register_handlers(self) -> None:
71+
"""Register Slack event handlers."""
72+
73+
@self.app.event("app_mention")
74+
async def handle_app_mention(event, say, logger) -> None:
75+
"""Handle app mentions and check for status requests."""
76+
try:
77+
text = event.get("text", "").lower()
78+
channel = event.get("channel")
79+
user = event.get("user")
80+
ts = event.get("ts")
81+
thread_ts = event.get(
82+
"thread_ts", ts
83+
) # Use thread_ts if in thread, else use message ts
84+
85+
logger.info(
86+
f"Received mention from user {user} in channel {channel}: {text}"
87+
)
88+
89+
# Check if message contains "status"
90+
if "status" not in text:
91+
logger.debug("Message doesn't contain 'status', ignoring")
92+
return
93+
94+
# Extract version tag from message
95+
tag = self._extract_version_tag(event.get("text", ""))
96+
97+
if not tag:
98+
# Reply in thread if configured
99+
if self.reply_in_thread:
100+
await self.app.client.chat_postMessage(
101+
channel=channel,
102+
thread_ts=thread_ts,
103+
text=f"<@{user}> I couldn't find a version tag in your message. "
104+
"Please mention me with 'status' and a version tag like `8.4-m01` or `7.2.5`.",
105+
)
106+
else:
107+
await say(
108+
f"<@{user}> I couldn't find a version tag in your message. "
109+
"Please mention me with 'status' and a version tag like `8.4-m01` or `7.2.5`."
110+
)
111+
return
112+
113+
logger.info(f"Processing status request for tag: {tag}")
114+
115+
# Post status for the tag
116+
await self._post_status(tag, channel, user, thread_ts)
117+
118+
except Exception as e:
119+
logger.error(f"Error handling app mention: {e}", exc_info=True)
120+
# Reply in thread if configured
121+
if self.reply_in_thread:
122+
await self.app.client.chat_postMessage(
123+
channel=event.get("channel"),
124+
thread_ts=event.get("thread_ts", event.get("ts")),
125+
text=f"Sorry, I encountered an error: {str(e)}",
126+
)
127+
else:
128+
await say(f"Sorry, I encountered an error: {str(e)}")
129+
130+
def _extract_version_tag(self, text: str) -> Optional[str]:
131+
"""Extract version tag from message text.
132+
133+
Args:
134+
text: Message text
135+
136+
Returns:
137+
Version tag if found, None otherwise
138+
"""
139+
match = VERSION_TAG_PATTERN.search(text)
140+
if match:
141+
return match.group(1)
142+
return None
143+
144+
async def _post_status(
145+
self, tag: str, channel: str, user: str, thread_ts: str
146+
) -> None:
147+
"""Load and post release status for a tag.
148+
149+
Args:
150+
tag: Release tag
151+
channel: Slack channel ID
152+
user: User ID who requested the status
153+
thread_ts: Thread timestamp to reply in
154+
"""
155+
try:
156+
# Create release args
157+
args = ReleaseArgs(
158+
release_tag=tag,
159+
force_rebuild=[],
160+
)
161+
162+
# Load state from S3
163+
storage = S3StateStorage()
164+
165+
# Use StateManager in read-only mode
166+
with StateManager(
167+
storage=storage,
168+
config=self.config,
169+
args=args,
170+
read_only=True,
171+
) as state_syncer:
172+
state = state_syncer.state
173+
174+
# Check if state exists (has any data beyond defaults)
175+
if not state.packages:
176+
if self.reply_in_thread:
177+
await self.app.client.chat_postMessage(
178+
channel=channel,
179+
thread_ts=thread_ts,
180+
text=f"<@{user}> No release state found for tag `{tag}`. "
181+
"This release may not have been started yet.",
182+
)
183+
else:
184+
await self.app.client.chat_postMessage(
185+
channel=channel,
186+
text=f"<@{user}> No release state found for tag `{tag}`. "
187+
"This release may not have been started yet.",
188+
)
189+
return
190+
191+
# Get status blocks from SlackStatePrinter
192+
printer = SlackStatePrinter(self.bot_token, channel)
193+
blocks = printer._make_blocks(state)
194+
text = f"Release {state.meta.tag or 'N/A'} — Status"
195+
196+
if self.reply_in_thread:
197+
await self.app.client.chat_postMessage(
198+
channel=channel,
199+
thread_ts=thread_ts,
200+
text=text,
201+
blocks=blocks,
202+
reply_broadcast=self.broadcast_to_channel,
203+
)
204+
else:
205+
await self.app.client.chat_postMessage(
206+
channel=channel,
207+
text=text,
208+
blocks=blocks,
209+
)
210+
211+
logger.info(
212+
f"Posted status for tag {tag} to channel {channel}"
213+
+ (f" in thread {thread_ts}" if self.reply_in_thread else "")
214+
)
215+
216+
except Exception as e:
217+
logger.error(f"Error posting status for tag {tag}: {e}", exc_info=True)
218+
if self.reply_in_thread:
219+
await self.app.client.chat_postMessage(
220+
channel=channel,
221+
thread_ts=thread_ts,
222+
text=f"<@{user}> Failed to load status for tag `{tag}`: {str(e)}",
223+
)
224+
else:
225+
await self.app.client.chat_postMessage(
226+
channel=channel,
227+
text=f"<@{user}> Failed to load status for tag `{tag}`: {str(e)}",
228+
)
229+
230+
async def start(self) -> None:
231+
"""Start the bot using Socket Mode."""
232+
logger.info("Starting Slack bot in Socket Mode...")
233+
handler = AsyncSocketModeHandler(self.app, self.app_token)
234+
await handler.start_async()
235+
236+
237+
async def run_bot(
238+
config_path: str = "config.yaml",
239+
slack_bot_token: Optional[str] = None,
240+
slack_app_token: Optional[str] = None,
241+
reply_in_thread: bool = True,
242+
broadcast_to_channel: bool = False,
243+
) -> None:
244+
"""Run the Slack bot.
245+
246+
Args:
247+
config_path: Path to config file
248+
slack_bot_token: Slack bot token (xoxb-...). If None, uses SLACK_BOT_TOKEN env var
249+
slack_app_token: Slack app token (xapp-...). If None, uses SLACK_APP_TOKEN env var
250+
reply_in_thread: If True, reply in thread. If False, reply in main channel
251+
broadcast_to_channel: If True and reply_in_thread is True, also show in main channel
252+
"""
253+
# Load config
254+
config = load_config(config_path)
255+
256+
# Create and start bot
257+
bot = ReleaseStatusBot(
258+
config=config,
259+
slack_bot_token=slack_bot_token,
260+
slack_app_token=slack_app_token,
261+
reply_in_thread=reply_in_thread,
262+
broadcast_to_channel=broadcast_to_channel,
263+
)
264+
265+
await bot.start()
266+
267+
268+
if __name__ == "__main__":
269+
# Setup logging
270+
logging.basicConfig(
271+
level=logging.INFO,
272+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
273+
)
274+
275+
# Run the bot
276+
asyncio.run(run_bot())

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)