diff --git a/enterprise/integrations/slack/slack_errors.py b/enterprise/integrations/slack/slack_errors.py index 1bf73d0f42..fd336fc1c2 100644 --- a/enterprise/integrations/slack/slack_errors.py +++ b/enterprise/integrations/slack/slack_errors.py @@ -26,6 +26,7 @@ class SlackErrorCode(Enum): PROVIDER_AUTH_FAILED = 'SLACK_ERR_006' LLM_AUTH_FAILED = 'SLACK_ERR_007' MISSING_SETTINGS = 'SLACK_ERR_008' + MISSING_SLACK_SCOPES = 'SLACK_ERR_009' UNEXPECTED_ERROR = 'SLACK_ERR_999' @@ -98,6 +99,11 @@ _USER_MESSAGES: dict[SlackErrorCode, str] = { '{username} please re-login into ' f'[OpenHands Cloud]({HOST_URL}) before starting a job.' ), + SlackErrorCode.MISSING_SLACK_SCOPES: ( + '⚠️ The Slack app is missing required permissions. ' + f'Please ask your workspace admin to re-install the OpenHands Slack App at {HOST_URL}/slack/install ' + 'to authorize the updated permissions.' + ), SlackErrorCode.UNEXPECTED_ERROR: ( 'Uh oh! There was an unexpected error (ref: {code}). Please try again later.' ), diff --git a/enterprise/integrations/slack/slack_view.py b/enterprise/integrations/slack/slack_view.py index a773d7f12f..bc78a0ccb9 100644 --- a/enterprise/integrations/slack/slack_view.py +++ b/enterprise/integrations/slack/slack_view.py @@ -5,6 +5,7 @@ from uuid import UUID, uuid4 from integrations.models import Message from integrations.resolver_context import ResolverUserContext from integrations.resolver_org_router import resolve_org_for_repo +from integrations.slack.slack_errors import SlackError, SlackErrorCode from integrations.slack.slack_types import ( SlackMessageView, SlackViewInterface, @@ -16,6 +17,7 @@ from integrations.utils import ( ) from jinja2 import Environment from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError from storage.slack_conversation import SlackConversation from storage.slack_conversation_store import SlackConversationStore from storage.slack_team_store import SlackTeamStore @@ -86,24 +88,34 @@ class SlackNewConversationView(SlackViewInterface): messages = [] if self.thread_ts: client = WebClient(token=self.bot_access_token) - result = client.conversations_replies( - channel=self.channel_id, - ts=self.thread_ts, - inclusive=True, - latest=self.message_ts, - limit=CONTEXT_LIMIT, # We can be smarter about getting more context/condensing it even in the future - ) + try: + result = client.conversations_replies( + channel=self.channel_id, + ts=self.thread_ts, + inclusive=True, + latest=self.message_ts, + limit=CONTEXT_LIMIT, # We can be smarter about getting more context/condensing it even in the future + ) + except SlackApiError as e: + if e.response.get('error') == 'missing_scope': + raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e + raise messages = result['messages'] else: client = WebClient(token=self.bot_access_token) - result = client.conversations_history( - channel=self.channel_id, - inclusive=True, - latest=self.message_ts, - limit=CONTEXT_LIMIT, - ) + try: + result = client.conversations_history( + channel=self.channel_id, + inclusive=True, + latest=self.message_ts, + limit=CONTEXT_LIMIT, + ) + except SlackApiError as e: + if e.response.get('error') == 'missing_scope': + raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e + raise messages = result['messages'] messages.reverse() @@ -280,13 +292,18 @@ class SlackUpdateExistingConversationView(SlackNewConversationView): async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]: client = WebClient(token=self.bot_access_token) - result = client.conversations_replies( - channel=self.channel_id, - ts=self.message_ts, - inclusive=True, - latest=self.message_ts, - limit=1, # Get exact user message, in future we can be smarter with collecting additional context - ) + try: + result = client.conversations_replies( + channel=self.channel_id, + ts=self.message_ts, + inclusive=True, + latest=self.message_ts, + limit=1, # Get exact user message, in future we can be smarter with collecting additional context + ) + except SlackApiError as e: + if e.response.get('error') == 'missing_scope': + raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e + raise user_message = result['messages'][0] user_message = self._get_initial_prompt( @@ -365,7 +382,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView): ) # 6. Send the message to the agent server - url = f"{agent_server_url.rstrip('/')}/api/conversations/{UUID(self.conversation_id)}/events" + url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events' headers = {'X-Session-API-Key': running_sandbox.session_api_key} payload = send_message_request.model_dump() diff --git a/enterprise/server/routes/integration/slack.py b/enterprise/server/routes/integration/slack.py index 3fba892c5d..c5b8e4b199 100644 --- a/enterprise/server/routes/integration/slack.py +++ b/enterprise/server/routes/integration/slack.py @@ -29,6 +29,7 @@ from server.constants import ( SLACK_WEBHOOKS_ENABLED, ) from server.logger import logger +from slack_sdk.errors import SlackApiError from slack_sdk.oauth import AuthorizeUrlGenerator from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient @@ -46,7 +47,16 @@ slack_router = APIRouter(prefix='/slack') # Build https://slack.com/oauth/v2/authorize with sufficient query parameters authorize_url_generator = AuthorizeUrlGenerator( - client_id=SLACK_CLIENT_ID, scopes=['app_mentions:read', 'chat:write'] + client_id=SLACK_CLIENT_ID, + scopes=[ + 'app_mentions:read', + 'chat:write', + 'users:read', + 'channels:history', + 'groups:history', + 'mpim:history', + 'im:history', + ], ) token_manager = TokenManager() @@ -232,7 +242,24 @@ async def keycloak_callback( # Retrieve the display_name from slack client = AsyncWebClient(token=bot_access_token) - slack_user_info = await client.users_info(user=slack_user_id) + try: + slack_user_info = await client.users_info(user=slack_user_id) + except SlackApiError as e: + if e.response.get('error') == 'missing_scope': + logger.warning( + 'slack_missing_scope_during_install', + extra={'slack_user_id': slack_user_id, 'team_id': team_id}, + ) + return _html_response( + title='Re-installation Required', + description=( + 'The Slack app is missing required permissions. ' + f'Please re-install the OpenHands Slack App ' + 'to authorize the updated permissions.' + ), + status_code=400, + ) + raise slack_display_name = slack_user_info.data['user']['profile']['display_name'] slack_user = SlackUser( keycloak_user_id=keycloak_user_id, @@ -366,7 +393,7 @@ async def on_options_load(request: Request, background_tasks: BackgroundTasks): # Verify this is a block_suggestion payload if payload.get('type') != 'block_suggestion': logger.warning( - f"slack_on_options_load: Unexpected payload type: {payload.get('type')}" + f'slack_on_options_load: Unexpected payload type: {payload.get("type")}' ) return JSONResponse({'options': []}) diff --git a/frontend/src/components/features/settings/git-settings/install-slack-app-anchor.tsx b/frontend/src/components/features/settings/git-settings/install-slack-app-anchor.tsx index 2e1ab30400..8442e5d3fe 100644 --- a/frontend/src/components/features/settings/git-settings/install-slack-app-anchor.tsx +++ b/frontend/src/components/features/settings/git-settings/install-slack-app-anchor.tsx @@ -12,11 +12,7 @@ export function InstallSlackAppAnchor() { variant="primary" className="w-55" onClick={() => - window.open( - "https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope=", - "_blank", - "noreferrer noopener", - ) + window.open("/slack/install", "_blank", "noreferrer noopener") } > {t(I18nKey.SLACK$INSTALL_APP)}