SlackController.java
/*
* BSD 2-Clause License
*
* Copyright (c) 2022, [Aleksandra Serba, Marcin Czerniak, Bartosz Wawrzyniak, Adrian Antkowiak]
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package dev.vernite.vernite.integration.communicator.slack;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.slack.api.bolt.App;
import com.slack.api.methods.SlackApiException;
import com.slack.api.methods.request.auth.AuthRevokeRequest;
import com.slack.api.methods.request.conversations.ConversationsHistoryRequest;
import com.slack.api.methods.request.conversations.ConversationsInfoRequest;
import com.slack.api.methods.request.conversations.ConversationsListRequest;
import com.slack.api.methods.request.conversations.ConversationsMembersRequest;
import com.slack.api.methods.request.oauth.OAuthV2AccessRequest;
import com.slack.api.methods.request.users.UsersInfoRequest;
import com.slack.api.methods.request.users.UsersListRequest;
import com.slack.api.methods.response.auth.AuthRevokeResponse;
import com.slack.api.methods.response.conversations.ConversationsHistoryResponse;
import com.slack.api.methods.response.conversations.ConversationsListResponse;
import com.slack.api.methods.response.oauth.OAuthV2AccessResponse;
import com.slack.api.methods.response.users.UsersInfoResponse;
import com.slack.api.model.ConversationType;
import dev.vernite.vernite.common.utils.SecureRandomUtils;
import dev.vernite.vernite.integration.communicator.model.Channel;
import dev.vernite.vernite.integration.communicator.model.ChatUser;
import dev.vernite.vernite.integration.communicator.slack.entity.SlackInstallation;
import dev.vernite.vernite.integration.communicator.slack.entity.SlackInstallationRepository;
import dev.vernite.vernite.integration.communicator.slack.model.SlackChannel;
import dev.vernite.vernite.integration.communicator.slack.model.SlackUser;
import dev.vernite.vernite.user.User;
import dev.vernite.vernite.user.UserRepository;
import dev.vernite.vernite.utils.ErrorType;
import dev.vernite.vernite.utils.ExternalApiException;
import dev.vernite.vernite.utils.ObjectNotFoundException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@RestController
public class SlackController {
private static final StateManager states = new StateManager();
private static final String FORMAT_URL = "https://slack.com/oauth/v2/authorize?client_id=%s&scope=&user_scope=%s&state=%s&redirect_uri=&granular_bot_scope=1";
@Autowired
private App app;
@Autowired
private UserRepository userRepository;
@Autowired
private SlackInstallationRepository installationRepository;
@Operation(summary = "Install slack", description = "This link redirects user to slack. After installation user will be redirected to https://vernite.dev/slack")
@ApiResponse(description = "No user logged in.", responseCode = "401", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@GetMapping("/integration/slack/install")
public void install(@NotNull @Parameter(hidden = true) User user, HttpServletResponse httpServletResponse)
throws IOException {
String userScope = app.config().getUserScope();
String clientId = app.config().getClientId();
String state = SecureRandomUtils.generateSecureRandomString();
states.put(state, user.getId());
httpServletResponse.sendRedirect(
String.format(FORMAT_URL, clientId, URLEncoder.encode(userScope, StandardCharsets.UTF_8), state));
}
@Hidden
@GetMapping("/integration/slack/oauth_redirect")
public void confirm(String code, String state, HttpServletResponse httpServletResponse)
throws IOException, SlackApiException {
Long userId = states.remove(state);
if (userId == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
User user = userRepository.findById(userId).orElseThrow();
OAuthV2AccessRequest request = OAuthV2AccessRequest.builder()
.clientId(app.config().getClientId())
.clientSecret(app.config().getClientSecret())
.code(code)
.build();
OAuthV2AccessResponse response = app.client().oauthV2Access(request);
try {
installationRepository.save(new SlackInstallation(response.getAuthedUser().getAccessToken(),
response.getAuthedUser().getId(), response.getTeam().getId(), response.getTeam().getName(), user));
} catch (Exception ex) {
// TODO: log this or something
}
httpServletResponse.sendRedirect("https://vernite.dev/slack");
}
@Operation(summary = "Get slack integrations", description = "Gets all slack integrations for given user")
@GetMapping("/user/integration/slack")
public List<SlackInstallation> getInstallation(@NotNull @Parameter(hidden = true) User user) {
return installationRepository.findByUser(user);
}
@Operation(summary = "Delete slack integration", description = "Delete slack integration with given id")
@ApiResponse(description = "No user logged in.", responseCode = "401", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@ApiResponse(description = "Integration with given id not found.", responseCode = "404", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@DeleteMapping("/user/integration/slack/{id}")
public void deleteInstallation(@NotNull @Parameter(hidden = true) User user, @PathVariable long id)
throws IOException, SlackApiException {
SlackInstallation installation = installationRepository.findById(id).orElseThrow(ObjectNotFoundException::new);
if (installation.getUser().getId() != user.getId()) {
throw new ObjectNotFoundException();
}
AuthRevokeResponse response = app.client()
.authRevoke(AuthRevokeRequest.builder().token(installation.getToken()).build());
if (!response.isOk()) {
// TODO: log this
}
installationRepository.delete(installation);
}
@Operation(summary = "Get channels", description = "Get channels for slack integration")
@ApiResponse(description = "Slack channels", responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = Channel.class))))
@ApiResponse(description = "No user logged in.", responseCode = "401", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@ApiResponse(description = "Integration with given id not found.", responseCode = "404", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@GetMapping("/user/integration/slack/{id}/channel")
public List<Channel> channels(@NotNull @Parameter(hidden = true) User user, @PathVariable long id)
throws IOException, SlackApiException {
SlackInstallation installation = installationRepository.findById(id).orElseThrow(ObjectNotFoundException::new);
if (installation.getUser().getId() != user.getId()) {
throw new ObjectNotFoundException();
}
ConversationsListResponse response = app.client()
.conversationsList(ConversationsListRequest.builder().token(installation.getToken())
.types(List.of(ConversationType.PRIVATE_CHANNEL, ConversationType.PUBLIC_CHANNEL,
ConversationType.IM, ConversationType.MPIM))
.build());
if (!response.isOk()) {
throw new ExternalApiException("slack", "Cannot get list of channels");
}
return response.getChannels().stream().map(c -> (Channel) new SlackChannel(c)).toList();
}
@Operation(summary = "Get user", description = "Get user info")
@ApiResponse(description = "Slack user", responseCode = "200", content = @Content(schema = @Schema(implementation = ChatUser.class)))
@ApiResponse(description = "No user logged in.", responseCode = "401", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@ApiResponse(description = "Integration with given id not found.", responseCode = "404", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@GetMapping("/user/integration/slack/{id}/user/{userId}")
public ChatUser getUser(@NotNull @Parameter(hidden = true) User user, @PathVariable long id,
@PathVariable String userId) throws IOException, SlackApiException {
SlackInstallation installation = installationRepository.findById(id).orElseThrow(ObjectNotFoundException::new);
if (installation.getUser().getId() != user.getId()) {
throw new ObjectNotFoundException();
}
UsersInfoResponse response = app.client()
.usersInfo(UsersInfoRequest.builder().token(installation.getToken()).user(userId).build());
if (!response.isOk()) {
throw new ExternalApiException("slack", "Cannot get user details");
}
return new SlackUser(response.getUser());
}
@Operation(summary = "Get usesrs", description = "Get users info")
@ApiResponse(description = "Slack users", responseCode = "200", content = @Content(schema = @Schema(implementation = ChatUser.class)))
@ApiResponse(description = "No user logged in.", responseCode = "401", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@ApiResponse(description = "Integration with given id not found.", responseCode = "404", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@GetMapping("/user/integration/slack/{id}/user")
public List<ChatUser> getUsers(@NotNull @Parameter(hidden = true) User user, @PathVariable long id)
throws IOException, SlackApiException {
SlackInstallation installation = installationRepository.findById(id).orElseThrow(ObjectNotFoundException::new);
if (installation.getUser().getId() != user.getId()) {
throw new ObjectNotFoundException();
}
var response = app.client()
.usersList(UsersListRequest.builder().token(installation.getToken()).build());
if (!response.isOk()) {
throw new ExternalApiException("slack", "Cannot get users");
}
return response.getMembers().stream().map(SlackUser::new).map(ChatUser.class::cast).toList();
}
@Operation(summary = "Get messages", description = "Get messages for slack channel")
@ApiResponse(description = "Slack messages", responseCode = "200", content = @Content(schema = @Schema(implementation = MessageContainer.class)))
@ApiResponse(description = "No user logged in.", responseCode = "401", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@ApiResponse(description = "Integration or channel with given id not found.", responseCode = "404", content = @Content(schema = @Schema(implementation = ErrorType.class)))
@GetMapping("/user/integration/slack/{id}/channel/{channelId}")
public MessageContainer messages(@NotNull @Parameter(hidden = true) User user, @PathVariable long id,
@PathVariable String channelId, @Parameter(required = false) String cursor)
throws IOException, SlackApiException {
SlackInstallation installation = installationRepository.findById(id).orElseThrow(ObjectNotFoundException::new);
if (installation.getUser().getId() != user.getId()) {
throw new ObjectNotFoundException();
}
ConversationsHistoryResponse response = app.client().conversationsHistory(
ConversationsHistoryRequest.builder().token(installation.getToken()).channel(channelId).cursor(cursor)
.build());
if (!response.isOk()) {
throw new ExternalApiException("slack", "Cannot get list of channels");
}
return new MessageContainer(response);
}
/**
* Get channel members.
*
* @param user logged in user
* @param id slack integration id
* @param channelId slack channel id
* @return list of channel members
* @throws SlackApiException
* @throws IOException
*/
@GetMapping("/user/integration/slack/{id}/channel/{channelId}/members")
public List<ChatUser> channelMembers(@NotNull @Parameter(hidden = true) User user, @PathVariable long id,
@PathVariable String channelId) throws IOException, SlackApiException {
SlackInstallation installation = installationRepository.findById(id).orElseThrow(ObjectNotFoundException::new);
if (installation.getUser().getId() != user.getId()) {
throw new ObjectNotFoundException();
}
var response = app.client().conversationsMembers(
ConversationsMembersRequest.builder().token(installation.getToken()).channel(channelId).build());
if (!response.isOk()) {
throw new ExternalApiException("slack", "Cannot get list of channel members");
}
return response.getMembers().stream().map(userId -> {
UsersInfoResponse userResponse;
try {
userResponse = app.client()
.usersInfo(UsersInfoRequest.builder().token(installation.getToken()).user(userId).build());
} catch (IOException | SlackApiException e) {
throw new ExternalApiException("slack", "Cannot get list of channel members");
}
if (!userResponse.isOk()) {
throw new ExternalApiException("slack", "Cannot get user details");
}
return (ChatUser) new SlackUser(userResponse.getUser());
}).toList();
}
/**
* Get channel.
*
* @param user logged in user
* @param id slack integration id
* @param channelId slack channel id
* @return slack channel
* @throws IOException
* @throws SlackApiException
*/
@GetMapping("/user/integration/slack/{id}/channel/{channelId}/info")
public Channel channel(@NotNull @Parameter(hidden = true) User user, @PathVariable long id,
@PathVariable String channelId)
throws IOException, SlackApiException {
SlackInstallation installation = installationRepository.findById(id).orElseThrow(ObjectNotFoundException::new);
if (installation.getUser().getId() != user.getId()) {
throw new ObjectNotFoundException();
}
var response = app.client().conversationsInfo(
ConversationsInfoRequest.builder().token(installation.getToken()).channel(channelId).build());
if (!response.isOk()) {
throw new ExternalApiException("slack", "Cannot get list of channels");
}
return (Channel) new SlackChannel(response.getChannel());
}
}