mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-06 21:44:00 -05:00
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Engel Nyst <engel.nyst@gmail.com> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
364 lines
14 KiB
Python
364 lines
14 KiB
Python
import re
|
|
|
|
import jwt
|
|
from integrations.manager import Manager
|
|
from integrations.models import Message, SourceType
|
|
from integrations.slack.slack_types import SlackViewInterface, StartingConvoException
|
|
from integrations.slack.slack_view import (
|
|
SlackFactory,
|
|
SlackNewConversationFromRepoFormView,
|
|
SlackNewConversationView,
|
|
SlackUnkownUserView,
|
|
SlackUpdateExistingConversationView,
|
|
)
|
|
from integrations.utils import (
|
|
HOST_URL,
|
|
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
|
)
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from pydantic import SecretStr
|
|
from server.auth.saas_user_auth import SaasUserAuth
|
|
from server.constants import SLACK_CLIENT_ID
|
|
from server.utils.conversation_callback_utils import register_callback_processor
|
|
from slack_sdk.oauth import AuthorizeUrlGenerator
|
|
from slack_sdk.web.async_client import AsyncWebClient
|
|
from storage.database import session_maker
|
|
from storage.slack_user import SlackUser
|
|
|
|
from openhands.core.logger import openhands_logger as logger
|
|
from openhands.integrations.provider import ProviderHandler
|
|
from openhands.integrations.service_types import Repository
|
|
from openhands.server.shared import config, server_config
|
|
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
|
from openhands.server.user_auth.user_auth import UserAuth
|
|
|
|
authorize_url_generator = AuthorizeUrlGenerator(
|
|
client_id=SLACK_CLIENT_ID,
|
|
scopes=['app_mentions:read', 'chat:write'],
|
|
user_scopes=['search:read'],
|
|
)
|
|
|
|
|
|
class SlackManager(Manager):
|
|
def __init__(self, token_manager):
|
|
self.token_manager = token_manager
|
|
self.login_link = (
|
|
'User has not yet authenticated: [Click here to Login to OpenHands]({}).'
|
|
)
|
|
|
|
self.jinja_env = Environment(
|
|
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'slack')
|
|
)
|
|
|
|
def _confirm_incoming_source_type(self, message: Message):
|
|
if message.source != SourceType.SLACK:
|
|
raise ValueError(f'Unexpected message source {message.source}')
|
|
|
|
async def _get_user_auth(self, keycloak_user_id: str) -> UserAuth:
|
|
offline_token = await self.token_manager.load_offline_token(keycloak_user_id)
|
|
if offline_token is None:
|
|
logger.info('no_offline_token_found')
|
|
|
|
user_auth = SaasUserAuth(
|
|
user_id=keycloak_user_id,
|
|
refresh_token=SecretStr(offline_token),
|
|
)
|
|
return user_auth
|
|
|
|
async def authenticate_user(
|
|
self, slack_user_id: str
|
|
) -> tuple[SlackUser | None, UserAuth | None]:
|
|
# We get the user and correlate them back to a user in OpenHands - if we can
|
|
slack_user = None
|
|
with session_maker() as session:
|
|
slack_user = (
|
|
session.query(SlackUser)
|
|
.filter(SlackUser.slack_user_id == slack_user_id)
|
|
.first()
|
|
)
|
|
|
|
# slack_view.slack_to_openhands_user = slack_user # attach user auth info to view
|
|
|
|
saas_user_auth = None
|
|
if slack_user:
|
|
saas_user_auth = await self._get_user_auth(slack_user.keycloak_user_id)
|
|
# slack_view.saas_user_auth = await self._get_user_auth(slack_view.slack_to_openhands_user.keycloak_user_id)
|
|
|
|
return slack_user, saas_user_auth
|
|
|
|
def _infer_repo_from_message(self, user_msg: str) -> str | None:
|
|
# Regular expression to match patterns like "OpenHands/OpenHands" or "deploy repo"
|
|
pattern = r'([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)|([a-zA-Z0-9_-]+)(?=\s+repo)'
|
|
match = re.search(pattern, user_msg)
|
|
|
|
if match:
|
|
repo = match.group(1) if match.group(1) else match.group(2)
|
|
return repo
|
|
|
|
return None
|
|
|
|
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
|
|
provider_tokens = await user_auth.get_provider_tokens()
|
|
if provider_tokens is None:
|
|
return []
|
|
access_token = await user_auth.get_access_token()
|
|
user_id = await user_auth.get_user_id()
|
|
client = ProviderHandler(
|
|
provider_tokens=provider_tokens,
|
|
external_auth_token=access_token,
|
|
external_auth_id=user_id,
|
|
)
|
|
repos: list[Repository] = await client.get_repositories(
|
|
'pushed', server_config.app_mode, None, None, None, None
|
|
)
|
|
return repos
|
|
|
|
def _generate_repo_selection_form(
|
|
self, repo_list: list[Repository], message_ts: str, thread_ts: str | None
|
|
):
|
|
options = [
|
|
{
|
|
'text': {'type': 'plain_text', 'text': 'No Repository'},
|
|
'value': '-',
|
|
}
|
|
]
|
|
options.extend(
|
|
{
|
|
'text': {
|
|
'type': 'plain_text',
|
|
'text': repo.full_name,
|
|
},
|
|
'value': repo.full_name,
|
|
}
|
|
for repo in repo_list
|
|
)
|
|
|
|
return [
|
|
{
|
|
'type': 'header',
|
|
'text': {
|
|
'type': 'plain_text',
|
|
'text': 'Choose a repository',
|
|
'emoji': True,
|
|
},
|
|
},
|
|
{
|
|
'type': 'actions',
|
|
'elements': [
|
|
{
|
|
'type': 'static_select',
|
|
'action_id': f'repository_select:{message_ts}:{thread_ts}',
|
|
'options': options,
|
|
}
|
|
],
|
|
},
|
|
]
|
|
|
|
def filter_potential_repos_by_user_msg(
|
|
self, user_msg: str, user_repos: list[Repository]
|
|
) -> tuple[bool, list[Repository]]:
|
|
inferred_repo = self._infer_repo_from_message(user_msg)
|
|
if not inferred_repo:
|
|
return False, user_repos[0:99]
|
|
|
|
final_repos = []
|
|
for repo in user_repos:
|
|
if inferred_repo.lower() in repo.full_name.lower():
|
|
final_repos.append(repo)
|
|
|
|
# no repos matched, return original list
|
|
if len(final_repos) == 0:
|
|
return False, user_repos[0:99]
|
|
|
|
# Found exact match
|
|
elif len(final_repos) == 1:
|
|
return True, final_repos
|
|
|
|
# Found partial matches
|
|
return False, final_repos[0:99]
|
|
|
|
async def receive_message(self, message: Message):
|
|
self._confirm_incoming_source_type(message)
|
|
|
|
slack_user, saas_user_auth = await self.authenticate_user(
|
|
slack_user_id=message.message['slack_user_id']
|
|
)
|
|
|
|
try:
|
|
slack_view = SlackFactory.create_slack_view_from_payload(
|
|
message, slack_user, saas_user_auth
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f'[Slack]: Failed to create slack view: {e}',
|
|
exc_info=True,
|
|
stack_info=True,
|
|
)
|
|
return
|
|
|
|
if isinstance(slack_view, SlackUnkownUserView):
|
|
jwt_secret = config.jwt_secret
|
|
if not jwt_secret:
|
|
raise ValueError('Must configure jwt_secret')
|
|
state = jwt.encode(
|
|
message.message, jwt_secret.get_secret_value(), algorithm='HS256'
|
|
)
|
|
link = authorize_url_generator.generate(state)
|
|
msg = self.login_link.format(link)
|
|
|
|
logger.info('slack_not_yet_authenticated')
|
|
await self.send_message(
|
|
self.create_outgoing_message(msg, ephemeral=True), slack_view
|
|
)
|
|
return
|
|
|
|
if not await self.is_job_requested(message, slack_view):
|
|
return
|
|
|
|
await self.start_job(slack_view)
|
|
|
|
async def send_message(self, message: Message, slack_view: SlackViewInterface):
|
|
client = AsyncWebClient(token=slack_view.bot_access_token)
|
|
if message.ephemeral and isinstance(message.message, str):
|
|
await client.chat_postEphemeral(
|
|
channel=slack_view.channel_id,
|
|
markdown_text=message.message,
|
|
user=slack_view.slack_user_id,
|
|
thread_ts=slack_view.thread_ts,
|
|
)
|
|
elif message.ephemeral and isinstance(message.message, dict):
|
|
await client.chat_postEphemeral(
|
|
channel=slack_view.channel_id,
|
|
user=slack_view.slack_user_id,
|
|
thread_ts=slack_view.thread_ts,
|
|
text=message.message['text'],
|
|
blocks=message.message['blocks'],
|
|
)
|
|
else:
|
|
await client.chat_postMessage(
|
|
channel=slack_view.channel_id,
|
|
markdown_text=message.message,
|
|
thread_ts=slack_view.message_ts,
|
|
)
|
|
|
|
async def is_job_requested(
|
|
self, message: Message, slack_view: SlackViewInterface
|
|
) -> bool:
|
|
"""
|
|
A job is always request we only receive webhooks for events associated with the slack bot
|
|
This method really just checks
|
|
1. Is the user is authenticated
|
|
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
|
|
"""
|
|
|
|
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
|
|
if isinstance(slack_view, SlackUpdateExistingConversationView):
|
|
return True
|
|
elif isinstance(slack_view, SlackNewConversationFromRepoFormView):
|
|
return True
|
|
elif isinstance(slack_view, SlackNewConversationView):
|
|
user = slack_view.slack_to_openhands_user
|
|
user_repos: list[Repository] = await self._get_repositories(
|
|
slack_view.saas_user_auth
|
|
)
|
|
match, repos = self.filter_potential_repos_by_user_msg(
|
|
slack_view.user_msg, user_repos
|
|
)
|
|
|
|
# User mentioned a matching repo is their message, start job without repo selection form
|
|
if match:
|
|
slack_view.selected_repo = repos[0].full_name
|
|
return True
|
|
|
|
logger.info(
|
|
'render_repository_selector',
|
|
extra={
|
|
'slack_user_id': user,
|
|
'keycloak_user_id': user.keycloak_user_id,
|
|
'message_ts': slack_view.message_ts,
|
|
'thread_ts': slack_view.thread_ts,
|
|
},
|
|
)
|
|
|
|
repo_selection_msg = {
|
|
'text': 'Choose a Repository:',
|
|
'blocks': self._generate_repo_selection_form(
|
|
repos, slack_view.message_ts, slack_view.thread_ts
|
|
),
|
|
}
|
|
await self.send_message(
|
|
self.create_outgoing_message(repo_selection_msg, ephemeral=True),
|
|
slack_view,
|
|
)
|
|
|
|
return False
|
|
|
|
return True
|
|
|
|
async def start_job(self, slack_view: SlackViewInterface):
|
|
# Importing here prevents circular import
|
|
from server.conversation_callback_processor.slack_callback_processor import (
|
|
SlackCallbackProcessor,
|
|
)
|
|
|
|
try:
|
|
msg_info = None
|
|
user_info: SlackUser = slack_view.slack_to_openhands_user
|
|
try:
|
|
logger.info(
|
|
f'[Slack] Starting job for user {user_info.slack_display_name} (id={user_info.slack_user_id})',
|
|
extra={'keyloak_user_id': user_info.keycloak_user_id},
|
|
)
|
|
conversation_id = await slack_view.create_or_update_conversation(
|
|
self.jinja_env
|
|
)
|
|
|
|
logger.info(
|
|
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
|
|
)
|
|
|
|
if not isinstance(slack_view, SlackUpdateExistingConversationView):
|
|
# We don't re-subscribe for follow up messages from slack.
|
|
# Summaries are generated for every messages anyways, we only need to do
|
|
# this subscription once for the event which kicked off the job.
|
|
processor = SlackCallbackProcessor(
|
|
slack_user_id=slack_view.slack_user_id,
|
|
channel_id=slack_view.channel_id,
|
|
message_ts=slack_view.message_ts,
|
|
thread_ts=slack_view.thread_ts,
|
|
team_id=slack_view.team_id,
|
|
)
|
|
|
|
# Register the callback processor
|
|
register_callback_processor(conversation_id, processor)
|
|
|
|
logger.info(
|
|
f'[Slack] Created callback processor for conversation {conversation_id}'
|
|
)
|
|
|
|
msg_info = slack_view.get_response_msg()
|
|
|
|
except MissingSettingsError as e:
|
|
logger.warning(
|
|
f'[Slack] Missing settings error for user {user_info.slack_display_name}: {str(e)}'
|
|
)
|
|
|
|
msg_info = f'{user_info.slack_display_name} please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
|
|
|
except LLMAuthenticationError as e:
|
|
logger.warning(
|
|
f'[Slack] LLM authentication error for user {user_info.slack_display_name}: {str(e)}'
|
|
)
|
|
|
|
msg_info = f'@{user_info.slack_display_name} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
|
|
|
except StartingConvoException as e:
|
|
msg_info = str(e)
|
|
|
|
await self.send_message(self.create_outgoing_message(msg_info), slack_view)
|
|
|
|
except Exception:
|
|
logger.exception('[Slack]: Error starting job')
|
|
msg = 'Uh oh! There was an unexpected error starting the job :('
|
|
await self.send_message(self.create_outgoing_message(msg), slack_view)
|