import asyncio import hashlib import hmac import logging import re from yuxi.channel.extensions.slack.types import SlackMode, SlackBotIdentity, SlackInboundEvent from yuxi.channel.extensions.slack.constants import SLACK_APP_TOKEN_PATTERN from yuxi.channel.extensions.slack.errors import SlackError, SlackErrorCode, SlackAuthError from yuxi.channel.extensions.slack.config import SlackConfigAdapter from yuxi.channel.context import ChannelContext logger = logging.getLogger(__name__) class SlackGateway: delivery_mode = "direct" def __init__(self): self._accounts: dict[str, dict] = {} async def start(self, ctx: ChannelContext) -> object: adapter = SlackConfigAdapter() config = getattr(ctx, "raw_config", None) or {} account = await adapter.resolve_account(ctx.account_id, config) if not account.get("bot_token"): raise SlackError(SlackErrorCode.CONFIG_ERROR, "bot_token is required") mode = account.get("mode", SlackMode.SOCKET) if mode == SlackMode.SOCKET: if not account.get("app_token"): raise SlackError(SlackErrorCode.CONFIG_ERROR, "app_token is required for socket mode") elif mode == SlackMode.HTTP: if not account.get("signing_secret"): raise SlackError(SlackErrorCode.CONFIG_ERROR, "signing_secret is required for HTTP mode") self._accounts[ctx.account_id] = account bot_identity = await self._auth_test(account) ctx._slack_bot_identity = bot_identity if mode == SlackMode.SOCKET: runtime = await self._start_socket_mode(ctx, account) else: runtime = await self._start_http_mode(ctx, account) return { "account": account, "identity": bot_identity, "mode": mode, "runtime": runtime, } async def stop(self, ctx: ChannelContext) -> None: account = self._accounts.pop(ctx.account_id, None) if account is None: return mode = account.get("mode", SlackMode.SOCKET) if mode == SlackMode.SOCKET: handler = getattr(ctx, "_slack_handler", None) if handler is not None: try: await handler.disconnect_async() except Exception: logger.warning("Error disconnecting socket mode handler", exc_info=True) try: await handler.close_async() except Exception: logger.warning("Error closing socket mode handler", exc_info=True) else: app = getattr(ctx, "_slack_app", None) if app is not None: logger.info("Slack HTTP mode app reference cleared for account %s", ctx.account_id) for attr in ("_slack_handler", "_slack_app", "_slack_bot_identity"): try: delattr(ctx, attr) except AttributeError: pass logger.info("Slack gateway stopped for account %s", ctx.account_id) def _register_event_handlers(self, app, ctx: ChannelContext) -> None: @app.event("message") async def on_message(body: dict, say): event = body.get("event", {}) subtype = event.get("subtype", "") if subtype == "message_changed": await self._on_message_changed(body, say, ctx) elif subtype == "message_deleted": await self._on_message_deleted(body, say, ctx) else: await self._on_message(body, say, ctx) @app.event("app_mention") async def on_app_mention(body: dict, say): await self._on_app_mention(body, say, ctx) @app.event("reaction_added") async def on_reaction_added(body: dict, say): await self._on_reaction_added(body, say, ctx) @app.event("reaction_removed") async def on_reaction_removed(body: dict, say): await self._on_reaction_removed(body, say, ctx) @app.event("pin_added") async def on_pin_added(body: dict, say): await self._on_pin_event("pin_added", body, say, ctx) @app.event("pin_removed") async def on_pin_removed(body: dict, say): await self._on_pin_event("pin_removed", body, say, ctx) @app.event("member_joined_channel") async def on_member_joined(body: dict, say): await self._on_member_event("member_joined_channel", body, say, ctx) @app.event("member_left_channel") async def on_member_left(body: dict, say): await self._on_member_event("member_left_channel", body, say, ctx) @app.event("channel_created") async def on_channel_created(body: dict, say): await self._on_channel_lifecycle("channel_created", body, say, ctx) @app.event("channel_archive") async def on_channel_archive(body: dict, say): await self._on_channel_lifecycle("channel_archive", body, say, ctx) @app.event("channel_unarchive") async def on_channel_unarchive(body: dict, say): await self._on_channel_lifecycle("channel_unarchive", body, say, ctx) @app.event("channel_rename") async def on_channel_rename(body: dict, say): await self._on_channel_lifecycle("channel_rename", body, say, ctx) @app.event("app_home_opened") async def on_app_home_opened(body: dict, say): await self._on_app_home_opened(body, say, ctx) @app.event("app_uninstalled") async def on_app_uninstalled(body: dict, say): await self._on_app_uninstalled(body, say, ctx) @app.event("link_shared") async def on_link_shared(body: dict, say): await self._on_link_shared(body, say, ctx) @app.event("file_created") async def on_file_created(body: dict, say): await self._on_file_event("file_created", body, say, ctx) @app.event("file_deleted") async def on_file_deleted(body: dict, say): await self._on_file_event("file_deleted", body, say, ctx) @app.event("file_shared") async def on_file_shared(body: dict, say): await self._on_file_event("file_shared", body, say, ctx) @app.event("user_change") async def on_user_change(body: dict, say): await self._on_user_change(body, say, ctx) @app.event("team_join") async def on_team_join(body: dict, say): await self._on_team_member_event("team_join", body, say, ctx) @app.event("dnd_updated") async def on_dnd_updated(body: dict, say): await self._on_dnd_updated(body, say, ctx) @app.event("message") async def on_message_replied(body: dict, say): event = body.get("event", {}) if event.get("subtype") == "message_replied": await self._on_message_replied(body, say, ctx) @app.action(re.compile(".*")) async def on_block_action(ack, body: dict, say): await ack() await self._on_block_action(body, say, ctx) @app.shortcut(re.compile(".*")) async def on_shortcut(ack, body: dict, say): await ack() await self._on_shortcut(body, say, ctx) @app.command("/help") async def on_help_command(ack, body: dict, say): await ack() await self._on_slash_command("help", body, say, ctx) @app.command("/reset") async def on_reset_command(ack, body: dict, say): await ack() await self._on_slash_command("reset", body, say, ctx) @app.view(re.compile(".*")) async def on_view_submission(ack, body: dict, say): await ack() await self._on_view_submission(body, say, ctx) @app.options(re.compile(".*")) async def on_external_select_options(ack, body: dict): await ack() await self._on_external_select_options(body, ctx) async def _on_view_submission(self, body: dict, say, ctx: ChannelContext) -> None: view = body.get("view", {}) callback_id = view.get("callback_id", "") state = view.get("state", {}).get("values", {}) user_id = body.get("user", {}).get("id", "") logger.info("Slack view_submission: callback_id=%s user=%s", callback_id, user_id) inbound = SlackInboundEvent( event_type="view_submission", event_ts=str(asyncio.get_event_loop().time()), channel=user_id, user=user_id, text=callback_id, ts=view.get("id", ""), raw={"body": body, "state": state}, team_id=body.get("team", {}).get("id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack view_submission event, user=%s", user_id) async def _on_external_select_options(self, body: dict, ctx: ChannelContext) -> None: action_id = body.get("action_id", "") query = body.get("value", "") user_id = body.get("user", {}).get("id", "") logger.info("Slack external_select_options: action_id=%s query=%s user=%s", action_id, query, user_id) async def _start_socket_mode(self, ctx: ChannelContext, account: dict) -> dict: try: from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.socket_mode.async_handler import SocketModeHandler except ImportError as e: raise SlackError( SlackErrorCode.CONFIG_ERROR, "slack_bolt package is required for socket mode. Install with: pip install slack-bolt", ) from e bot_token = account["bot_token"] app_token = account["app_token"] self._validate_app_token(app_token) app = AsyncApp(token=bot_token) self._register_event_handlers(app, ctx) handler = SocketModeHandler(app, app_token=app_token) await handler.connect_async() auth_info = await app.client.auth_test() token_api_app_id = auth_info.get("app_id", "") app_token_match = re.match(r"^xapp-\d+-([A-Za-z0-9]+)-", app_token) app_token_app_id = app_token_match.group(1) if app_token_match else "" if token_api_app_id and app_token_app_id and token_api_app_id != app_token_app_id: logger.warning( "api_app_id mismatch: bot_token app_id=%s, app_token app_id=%s", token_api_app_id, app_token_app_id, ) ctx._slack_handler = handler ctx._slack_app = app while not ctx.cancel_event.is_set(): await asyncio.sleep(1) return {"handler": handler, "app": app} async def _start_http_mode(self, ctx: ChannelContext, account: dict) -> dict: try: from slack_bolt.async_app import AsyncApp except ImportError as e: raise SlackError( SlackErrorCode.CONFIG_ERROR, "slack_bolt package is required for HTTP mode. Install with: pip install slack-bolt", ) from e bot_token = account["bot_token"] signing_secret = account["signing_secret"] app = AsyncApp(token=bot_token, signing_secret=signing_secret) self._register_event_handlers(app, ctx) ctx._slack_app = app ctx._slack_signing_secret = signing_secret logger.info("Slack HTTP mode registered for account %s", ctx.account_id) return { "app": app, "route_registered": True, "events_path": "/api/channel/slack/events", } async def process_event(self, ctx: ChannelContext, body: dict, headers: dict) -> dict: signing_secret = getattr(ctx, "_slack_signing_secret", "") if signing_secret: raw_body = headers.get("_raw_body", "") signature = headers.get("X-Slack-Signature", "") timestamp = headers.get("X-Slack-Request-Timestamp", "") if not self._verify_slack_signature(signing_secret, timestamp, raw_body, signature): return {"status": 403, "body": "Invalid signature"} event_type = body.get("type", "") if event_type == "url_verification": return {"status": 200, "body": body.get("challenge", "")} app = getattr(ctx, "_slack_app", None) if app is not None: asyncio.create_task(app.process_event(body)) else: logger.warning("No Slack app available for event processing") return {"status": 200, "body": "ok"} @staticmethod def _verify_slack_signature( signing_secret: str, timestamp: str, raw_body: str, signature: str, ) -> bool: if not signing_secret or not timestamp or not raw_body or not signature: return False version = "v0" base = f"{version}:{timestamp}:{raw_body}" computed = f"{version}={ hmac.new( signing_secret.encode('utf-8'), base.encode('utf-8'), hashlib.sha256, ).hexdigest() }" return hmac.compare_digest(computed, signature) async def _on_message(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) channel_type = event.get("channel_type", "") subtype = event.get("subtype", "") if subtype == "message_changed": return if subtype and subtype != "bot_message": return bot_id = event.get("bot_id", "") if bot_id: return inbound = SlackInboundEvent( event_type="message", event_ts=event.get("event_ts", ""), channel=event.get("channel", ""), channel_type=channel_type, user=event.get("user", ""), text=event.get("text", ""), ts=event.get("ts", ""), thread_ts=event.get("thread_ts", ""), bot_id=bot_id, subtype=subtype, files=event.get("files", []), raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack message event, channel=%s", inbound.channel) async def _on_app_mention(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) channel_type = event.get("channel_type", "") if channel_type == "im": return inbound = SlackInboundEvent( event_type="app_mention", event_ts=event.get("event_ts", ""), channel=event.get("channel", ""), channel_type=channel_type, user=event.get("user", ""), text=event.get("text", ""), ts=event.get("ts", ""), thread_ts=event.get("thread_ts", ""), bot_id=event.get("bot_id", ""), subtype=event.get("subtype", ""), files=event.get("files", []), raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack app_mention event, channel=%s", inbound.channel) async def _on_message_changed(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) if event.get("subtype") != "message_changed": return message = event.get("message", {}) channel_type = event.get("channel_type", "") if channel_type != "im": return bot_id = message.get("bot_id", "") bot_identity: SlackBotIdentity = getattr(ctx, "_slack_bot_identity", None) if not bot_identity or not bot_id or bot_id != bot_identity.bot_id: return previous_message = event.get("previous_message", {}) edited_by = message.get("edited", {}).get("user", "") text = message.get("text", "") user = message.get("user", "") if not text and previous_message: text = previous_message.get("text", "") user = edited_by or user if not text or not user: return inbound = SlackInboundEvent( event_type="message_changed", event_ts=event.get("event_ts", ""), channel=event.get("channel", ""), channel_type=channel_type, user=user, text=text, ts=message.get("ts", ""), thread_ts=message.get("thread_ts", ""), bot_id="", subtype="message_changed", files=[], raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack message_changed event, channel=%s", inbound.channel) async def _on_reaction_added(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) reaction = event.get("reaction", "") item = event.get("item", {}) logger.info( "Slack reaction_added: reaction=%s channel=%s ts=%s user=%s", reaction, item.get("channel", ""), item.get("ts", ""), event.get("user", ""), ) inbound = SlackInboundEvent( event_type="reaction_added", event_ts=event.get("event_ts", ""), channel=item.get("channel", ""), channel_type=item.get("type", ""), user=event.get("user", ""), text=reaction, ts=item.get("ts", ""), raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack reaction_added event, channel=%s", inbound.channel) async def _on_message_deleted(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) deleted_ts = event.get("deleted_ts", "") channel = event.get("channel", "") logger.info( "Slack message_deleted: channel=%s deleted_ts=%s user=%s", channel, deleted_ts, event.get("user", ""), ) inbound = SlackInboundEvent( event_type="message_deleted", event_ts=event.get("event_ts", ""), channel=channel, user=event.get("user", ""), ts=deleted_ts, raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack message_deleted event, channel=%s", inbound.channel) async def _on_block_action(self, body: dict, say, ctx: ChannelContext) -> None: action = body.get("actions", [{}])[0] action_id = action.get("action_id", "") value = action.get("value", "") user_id = body.get("user", {}).get("id", "") channel_id = body.get("channel", {}).get("id", "") logger.info( "Slack block_action: action_id=%s value=%s user=%s channel=%s", action_id, value, user_id, channel_id, ) inbound = SlackInboundEvent( event_type="block_action", event_ts=str(asyncio.get_event_loop().time()), channel=channel_id, channel_type=body.get("channel", {}).get("type", ""), user=user_id, text=value, ts=body.get("message", {}).get("ts", ""), raw=body, team_id=body.get("team", {}).get("id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack block_action event, channel=%s", inbound.channel) async def _on_slash_command(self, command: str, body: dict, say, ctx: ChannelContext) -> None: user_id = body.get("user_id", "") channel_id = body.get("channel_id", "") text = body.get("text", "") logger.info( "Slack slash_command: command=%s user=%s channel=%s", command, user_id, channel_id, ) inbound = SlackInboundEvent( event_type="slash_command", event_ts=str(body.get("ts", asyncio.get_event_loop().time())), channel=channel_id, channel_type=body.get("channel_name", ""), user=user_id, text=f"/{command} {text}".strip(), ts="", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack slash_command event, channel=%s", inbound.channel) async def _on_reaction_removed(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) reaction = event.get("reaction", "") item = event.get("item", {}) logger.info("Slack reaction_removed: reaction=%s channel=%s", reaction, item.get("channel", "")) inbound = SlackInboundEvent( event_type="reaction_removed", event_ts=event.get("event_ts", ""), channel=item.get("channel", ""), channel_type=item.get("type", ""), user=event.get("user", ""), text=reaction, ts=item.get("ts", ""), raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack reaction_removed event, channel=%s", inbound.channel) async def _on_pin_event(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) item = event.get("item", {}) channel = item.get("channel", "") logger.info("Slack %s: channel=%s", event_type, channel) inbound = SlackInboundEvent( event_type=event_type, event_ts=event.get("event_ts", ""), channel=channel, user=event.get("user", ""), text="", ts=item.get("message", {}).get("ts", "") if item else "", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack %s event, channel=%s", event_type, inbound.channel) async def _on_member_event(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) channel = event.get("channel", "") user = event.get("user", "") logger.info("Slack %s: user=%s channel=%s", event_type, user, channel) inbound = SlackInboundEvent( event_type=event_type, event_ts=event.get("event_ts", ""), channel=channel, user=user, text="", ts="", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack %s event, channel=%s", event_type, inbound.channel) async def _on_channel_lifecycle(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) channel = event.get("channel", {}) channel_id = channel.get("id", "") if isinstance(channel, dict) else event.get("channel", "") logger.info("Slack %s: channel=%s", event_type, channel_id) inbound = SlackInboundEvent( event_type=event_type, event_ts=event.get("event_ts", ""), channel=channel_id, user=event.get("user", ""), text=channel.get("name", "") if isinstance(channel, dict) else "", ts="", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack %s event, channel=%s", event_type, inbound.channel) async def _on_app_home_opened(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) user = event.get("user", "") logger.info("Slack app_home_opened: user=%s", user) inbound = SlackInboundEvent( event_type="app_home_opened", event_ts=event.get("event_ts", ""), channel=user, user=user, text="", ts="", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack app_home_opened event, user=%s", user) async def _on_app_uninstalled(self, body: dict, say, ctx: ChannelContext) -> None: team_id = body.get("team_id", "") logger.info("Slack app_uninstalled: team=%s", team_id) inbound = SlackInboundEvent( event_type="app_uninstalled", event_ts=str(asyncio.get_event_loop().time()), channel=team_id, user="", text="", ts="", raw=body, team_id=team_id, api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack app_uninstalled event, team=%s", team_id) async def _on_link_shared(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) channel = event.get("channel", "") links = event.get("links", []) logger.info("Slack link_shared: channel=%s links=%d", channel, len(links)) inbound = SlackInboundEvent( event_type="link_shared", event_ts=event.get("event_ts", ""), channel=channel, user=event.get("user", ""), text=",".join([link.get("url", "") for link in links]), ts=event.get("message_ts", ""), raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack link_shared event, channel=%s", channel) async def _on_file_event(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) file_id = event.get("file_id", "") logger.info("Slack %s: file_id=%s", event_type, file_id) inbound = SlackInboundEvent( event_type=event_type, event_ts=event.get("event_ts", ""), channel="", user=event.get("user_id", ""), text=file_id, ts="", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack %s event, file_id=%s", event_type, file_id) async def _on_user_change(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) user = event.get("user", {}) user_id = user.get("id", "") if isinstance(user, dict) else event.get("user", "") logger.info("Slack user_change: user=%s", user_id) inbound = SlackInboundEvent( event_type="user_change", event_ts=event.get("event_ts", ""), channel="", user=user_id, text="", ts="", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack user_change event, user=%s", user_id) async def _on_team_member_event(self, event_type: str, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) user = event.get("user", {}) user_id = user.get("id", "") if isinstance(user, dict) else "" logger.info("Slack %s: user=%s", event_type, user_id) inbound = SlackInboundEvent( event_type=event_type, event_ts=event.get("event_ts", ""), channel="", user=user_id, text="", ts="", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack %s event, user=%s", event_type, user_id) async def _on_dnd_updated(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) user = event.get("user", "") dnd_status = event.get("dnd_status", {}) logger.info("Slack dnd_updated: user=%s snooze=%s", user, dnd_status.get("snooze_enabled", False)) inbound = SlackInboundEvent( event_type="dnd_updated", event_ts=event.get("event_ts", ""), channel="", user=user, text=str(dnd_status.get("snooze_enabled", False)), ts="", raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack dnd_updated event, user=%s", user) async def _on_message_replied(self, body: dict, say, ctx: ChannelContext) -> None: event = body.get("event", {}) message = event.get("message", {}) channel = event.get("channel", "") thread_ts = message.get("thread_ts", "") logger.info("Slack message_replied: channel=%s thread=%s", channel, thread_ts) inbound = SlackInboundEvent( event_type="message_replied", event_ts=event.get("event_ts", ""), channel=channel, user=message.get("user", ""), text=message.get("text", ""), ts=thread_ts, thread_ts=thread_ts, raw=body, team_id=body.get("team_id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack message_replied event, channel=%s", channel) async def _on_shortcut(self, body: dict, say, ctx: ChannelContext) -> None: callback_id = body.get("callback_id", "") trigger_id = body.get("trigger_id", "") user_id = body.get("user", {}).get("id", "") logger.info("Slack shortcut: callback_id=%s user=%s", callback_id, user_id) inbound = SlackInboundEvent( event_type="shortcut", event_ts=str(asyncio.get_event_loop().time()), channel=body.get("channel", {}).get("id", ""), user=user_id, text=callback_id, ts=trigger_id, raw=body, team_id=body.get("team", {}).get("id", ""), api_app_id=body.get("api_app_id", ""), ) if ctx.queue is not None: await ctx.queue.put(inbound) else: logger.warning("No queue available for Slack shortcut event, user=%s", user_id) async def _auth_test(self, account: dict) -> SlackBotIdentity: bot_token = account["bot_token"] try: from slack_sdk.web.async_client import AsyncWebClient except ImportError as e: raise SlackError( SlackErrorCode.CONFIG_ERROR, "slack_sdk package is required. Install with: pip install slack-sdk", ) from e client = AsyncWebClient(token=bot_token) try: auth_info = await client.auth_test() except Exception as e: raise SlackAuthError(f"auth.test failed: {e}") from e if not auth_info.get("ok"): error_msg = auth_info.get("error", "unknown") raise SlackAuthError(f"auth.test returned error: {error_msg}") return SlackBotIdentity( bot_user_id=auth_info.get("user_id", ""), bot_id=auth_info.get("bot_id", ""), team_id=auth_info.get("team_id", ""), team_name=auth_info.get("team", ""), api_app_id=auth_info.get("app_id", ""), workspace_url=auth_info.get("url", ""), user_id=auth_info.get("user_id", ""), ) @staticmethod def _validate_app_token(app_token: str) -> bool: if not app_token: raise SlackError(SlackErrorCode.CONFIG_ERROR, "app_token is empty") if not re.match(SLACK_APP_TOKEN_PATTERN, app_token): raise SlackError( SlackErrorCode.CONFIG_ERROR, f"app_token does not match expected pattern: {SLACK_APP_TOKEN_PATTERN}", ) return True def resolve_gateway_auth_bypass_paths(self, config: dict) -> list[str]: return ["/api/channel/slack/events"]