diff --git a/bolt-jakarta-socket-mode/pom.xml b/bolt-jakarta-socket-mode/pom.xml new file mode 100644 index 000000000..3e15e8e76 --- /dev/null +++ b/bolt-jakarta-socket-mode/pom.xml @@ -0,0 +1,97 @@ + + 4.0.0 + + + com.slack.api + slack-sdk-parent + 1.41.1-SNAPSHOT + + + + 2.2.0 + 2.2.0 + + + bolt-jakarta-socket-mode + 1.41.1-SNAPSHOT + jar + + + + com.slack.api + slack-api-model + ${project.version} + + + com.slack.api + slack-api-client + ${project.version} + + + javax.websocket + javax.websocket-api + + + org.glassfish.tyrus.bundles + tyrus-standalone-client + + + + + com.slack.api + slack-jakarta-socket-mode-client + ${project.version} + + + com.slack.api + slack-app-backend + ${project.version} + + + com.slack.api + bolt + ${project.version} + + + + jakarta.websocket + jakarta.websocket-client-api + ${jakarta.websocket-api.version} + provided + + + org.glassfish.tyrus.bundles + tyrus-standalone-client + ${tyrus-standalone-client.version} + provided + + + + org.eclipse.jetty + jetty-servlet + ${jetty-for-tests.version} + test + + + org.eclipse.jetty + jetty-server + ${jetty-for-tests.version} + test + + + org.eclipse.jetty + jetty-webapp + ${jetty-for-tests.version} + test + + + org.eclipse.jetty.websocket + websocket-server + ${jetty-for-tests.version} + test + + + + diff --git a/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/SocketModeApp.java b/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/SocketModeApp.java new file mode 100644 index 000000000..10f683d8c --- /dev/null +++ b/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/SocketModeApp.java @@ -0,0 +1,212 @@ +package com.slack.api.bolt.jakarta_socket_mode; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.slack.api.bolt.App; +import com.slack.api.bolt.request.Request; +import com.slack.api.bolt.response.Response; +import com.slack.api.bolt.jakarta_socket_mode.request.SocketModeRequest; +import com.slack.api.bolt.jakarta_socket_mode.request.SocketModeRequestParser; +import com.slack.api.jakarta_socket_mode.JakartaSocketModeClientFactory; +import com.slack.api.socket_mode.SocketModeClient; +import com.slack.api.socket_mode.response.AckResponse; +import com.slack.api.util.json.GsonFactory; +import lombok.Builder; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +@Slf4j +public class SocketModeApp { + private boolean clientStopped = true; + private final App app; + private final Supplier clientFactory; + private SocketModeClient client; + + private static final Function DEFAULT_ERROR_HANDLER = (context) -> { + Exception e = context.getException(); + log.error("Failed to handle a request: {}", e.getMessage(), e); + return null; + }; + + @Data + @Builder + public static class ErrorContext { + private Request request; + private Exception exception; + } + + // ------------------------------------------- + + private static void sendSocketModeResponse( + SocketModeClient client, + Gson gson, + SocketModeRequest req, + Response boltResponse + ) { + if (boltResponse.getBody() != null) { + Map response = new HashMap<>(); + if (boltResponse.getContentType().startsWith("application/json")) { + response.put("envelope_id", req.getEnvelope().getEnvelopeId()); + response.put("payload", gson.fromJson(boltResponse.getBody(), JsonElement.class)); + } else { + response.put("envelope_id", req.getEnvelope().getEnvelopeId()); + Map payload = new HashMap<>(); + payload.put("text", boltResponse.getBody()); + response.put("payload", payload); + } + client.sendSocketModeResponse(gson.toJson(response)); + } else { + client.sendSocketModeResponse(new AckResponse(req.getEnvelope().getEnvelopeId())); + } + } + + private static Supplier buildSocketModeClientFactory( + App app, + String appToken, + Function errorHandler + ) { + return () -> { + try { + final SocketModeClient client = JakartaSocketModeClientFactory.create(app.slack(), appToken); + final SocketModeRequestParser requestParser = new SocketModeRequestParser(app.config()); + final Gson gson = GsonFactory.createSnakeCase(app.slack().getConfig()); + client.addWebSocketMessageListener(message -> { + long startMillis = System.currentTimeMillis(); + SocketModeRequest req = requestParser.parse(message); + if (req != null) { + try { + Response boltResponse = app.run(req.getBoltRequest()); + if (boltResponse.getStatusCode() != 200) { + log.warn("Unsuccessful Bolt app execution (status: {}, body: {})", + boltResponse.getStatusCode(), boltResponse.getBody()); + return; + } + sendSocketModeResponse(client, gson, req, boltResponse); + } catch (Exception e) { + ErrorContext context = ErrorContext.builder().request(req.getBoltRequest()).exception(e).build(); + Response errorResponse = errorHandler.apply(context); + if (errorResponse != null) { + sendSocketModeResponse(client, gson, req, errorResponse); + } + } finally { + long spentMillis = System.currentTimeMillis() - startMillis; + log.debug("Response time: {} milliseconds", spentMillis); + } + } + }); + return client; + } catch (IOException e) { + log.error("Failed to start a new Socket Mode client (error: {})", e.getMessage(), e); + return null; + } + }; + } + + public SocketModeApp(App app) throws IOException { + this(System.getenv("SLACK_APP_TOKEN"), app); + } + + + public SocketModeApp(String appToken, App app) throws IOException { + this(appToken, DEFAULT_ERROR_HANDLER, app); + } + + public SocketModeApp( + String appToken, + Function errorHandler, + App app + ) throws IOException { + this(buildSocketModeClientFactory(app, appToken, errorHandler), app); + } + + public SocketModeApp( + String appToken, + App app, + Function errorHandler + ) throws IOException { + this(buildSocketModeClientFactory(app, appToken, errorHandler), app); + } + + public SocketModeApp(Supplier clientFactory, App app) { + this.clientFactory = clientFactory; + this.app = app; + } + + /** + * If you would like to synchronously detect the connection error as an exception when bootstrapping, + * use this constructor. The first line can throw an exception + * in the case where either the token or network settings are valid. + * + * + * SocketModeClient client = JakartaSocketModeClientFactory.create(appToken); + * SocketModeApp socketModeApp = new SocketModeApp(client, app); + * + */ + public SocketModeApp(SocketModeClient socketModeClient, App app) { + this.client = socketModeClient; + this.clientFactory = () -> socketModeClient; + this.app = app; + } + + // ------------------------------------------- + + public void start() throws Exception { + run(true); + } + + public void startAsync() throws Exception { + run(false); + } + + public void run(boolean blockCurrentThread) throws Exception { + this.app.start(); + if (this.client == null) { + this.client = clientFactory.get(); + } + if (this.isClientStopped()) { + this.client.connectToNewEndpoint(); + } else { + this.client.connect(); + } + this.client.setAutoReconnectEnabled(true); + this.clientStopped = false; + if (blockCurrentThread) { + Thread.sleep(Long.MAX_VALUE); + } + } + + public void stop() throws Exception { + if (this.client != null && this.client.verifyConnection()) { + this.client.disconnect(); + } + this.clientStopped = true; + this.app.stop(); + } + + public void close() throws Exception { + this.stop(); + this.client = null; + } + + // ------------------------------------------- + // Accessors + // ------------------------------------------- + + public boolean isClientStopped() { + return clientStopped; + } + + public SocketModeClient getClient() { + return client; + } + + public App getApp() { + return app; + } +} diff --git a/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/package-info.java b/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/package-info.java new file mode 100644 index 000000000..b355ba3af --- /dev/null +++ b/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/package-info.java @@ -0,0 +1,4 @@ +/** + * Built-in Socket Mode adapter supports. + */ +package com.slack.api.bolt.jakarta_socket_mode; \ No newline at end of file diff --git a/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/request/SocketModeRequest.java b/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/request/SocketModeRequest.java new file mode 100644 index 000000000..dcb9e8a63 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/request/SocketModeRequest.java @@ -0,0 +1,17 @@ +package com.slack.api.bolt.jakarta_socket_mode.request; + +import com.slack.api.bolt.request.Request; +import com.slack.api.socket_mode.request.SocketModeEnvelope; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class SocketModeRequest { + private SocketModeEnvelope envelope; + private Request boltRequest; +} diff --git a/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/request/SocketModeRequestParser.java b/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/request/SocketModeRequestParser.java new file mode 100644 index 000000000..b2406cd1c --- /dev/null +++ b/bolt-jakarta-socket-mode/src/main/java/com/slack/api/bolt/jakarta_socket_mode/request/SocketModeRequestParser.java @@ -0,0 +1,66 @@ +package com.slack.api.bolt.jakarta_socket_mode.request; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.request.RequestHeaders; +import com.slack.api.bolt.util.SlackRequestParser; +import com.slack.api.socket_mode.request.EventsApiEnvelope; +import com.slack.api.socket_mode.request.InteractiveEnvelope; +import com.slack.api.socket_mode.request.SlashCommandsEnvelope; +import com.slack.api.socket_mode.request.SocketModeEnvelope; +import com.slack.api.util.json.GsonFactory; +import lombok.Data; + +import java.util.*; + +public class SocketModeRequestParser { + private static final Gson GSON = GsonFactory.createSnakeCase(); + private final SlackRequestParser slackRequestParser; + + public SocketModeRequestParser(AppConfig appConfig) { + this.slackRequestParser = new SlackRequestParser(appConfig); + } + + @Data + public static class GenericSocketModeEnvelope implements SocketModeEnvelope { + private String type; + private String envelopeId; + private Boolean acceptsResponsePayload; + private JsonElement payload; + private Integer retryAttempt; + private String retryReason; + } + + private static final List ENVELOPE_TYPES = Arrays.asList( + EventsApiEnvelope.TYPE, + InteractiveEnvelope.TYPE, + SlashCommandsEnvelope.TYPE + ); + + public SocketModeRequest parse(String message) { + GenericSocketModeEnvelope envelope = GSON.fromJson(message, GenericSocketModeEnvelope.class); + if (ENVELOPE_TYPES.contains(envelope.getType())) { + Map> headers = new HashMap<>(); + if (envelope.getRetryAttempt() != null) { + headers.put("X-Slack-Retry-Num", Arrays.asList(String.valueOf(envelope.getRetryAttempt()))); + } + if (envelope.getRetryReason() != null) { + headers.put("X-Slack-Retry-Reason", Arrays.asList(envelope.getRetryReason())); + } + return SocketModeRequest.builder() + .envelope(envelope) + .boltRequest(slackRequestParser.parse(SlackRequestParser.HttpRequest.builder() + .socketMode(true) + .requestUri("") + .remoteAddress("") + .queryString(Collections.emptyMap()) + .requestBody(GSON.toJson(envelope.getPayload())) + .headers(new RequestHeaders(headers)) + .build())) + .build(); + } + return null; + } + +} diff --git a/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/ServletAdapterOps.java b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/ServletAdapterOps.java new file mode 100644 index 000000000..7e7d6df7e --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/ServletAdapterOps.java @@ -0,0 +1,47 @@ +package com.slack.api.bolt.servlet; + +import com.slack.api.bolt.response.Response; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Common utilities for Servlet compatibilities. + */ +public class ServletAdapterOps { + private ServletAdapterOps() { + } + + public static String doReadRequestBodyAsString(HttpServletRequest req) throws IOException { + return req.getReader().lines().collect(Collectors.joining(System.lineSeparator())); + } + + public static Map> toHeaderMap(HttpServletRequest req) { + Map> headers = new HashMap<>(); + Enumeration names = req.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + List values = Collections.list(req.getHeaders(name)); + headers.put(name, values); + } + return headers; + } + + public static void writeResponse(HttpServletResponse resp, Response slackResp) throws IOException { + resp.setStatus(slackResp.getStatusCode()); + for (Map.Entry> header : slackResp.getHeaders().entrySet()) { + String name = header.getKey(); + for (String value : header.getValue()) { + resp.addHeader(name, value); + } + } + resp.setHeader("Content-Type", slackResp.getContentType()); + if (slackResp.getBody() != null) { + resp.getWriter().write(slackResp.getBody()); + } + } + +} diff --git a/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServer.java b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServer.java new file mode 100644 index 000000000..b5b53a8f3 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServer.java @@ -0,0 +1,156 @@ +package com.slack.api.bolt.servlet; + +import com.slack.api.bolt.App; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jetty.server.ConnectionFactory; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.ErrorHandler; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.io.Writer; +import java.util.HashMap; +import java.util.Map; + +/** + * An HTTP server backed by Jetty HTTP Server that runs {@link App} apps. + * + * @see Jetty HTTP Server + */ +@Slf4j +public class SlackAppServer { + + private final Server server; + private final Map pathToApp; + private final boolean localDebug = System.getenv("SLACK_APP_LOCAL_DEBUG") != null; + + // This is intentionally mutable to allow developers to register their own one + private ErrorHandler errorHandler = new ErrorHandler() { + @Override + protected void writeErrorPage( + HttpServletRequest request, + Writer writer, + int code, + String message, + boolean showStacks) throws IOException { + if (localDebug) { + super.writeErrorPage(request, writer, code, message, showStacks); + } else { + writer.write("{\"status\":\"" + code + "\"}"); + } + } + }; + + public SlackAppServer(App app) { + this(app, "/slack/events", 3000); + } + + public SlackAppServer(App app, String path) { + this(app, path, 3000); + } + + public SlackAppServer(App app, int port) { + this(toApps(app, "/slack/events"), port); + } + + public SlackAppServer(App app, String path, int port) { + this(toApps(app, path), port); + } + + public SlackAppServer(Map pathToApp) { + this(pathToApp, 3000); + } + + public SlackAppServer(Map pathToApp, int port) { + this.pathToApp = pathToApp; + server = new Server(port); + removeServerHeader(server); + + ServletContextHandler handler = new ServletContextHandler(); + Map addedOnes = new HashMap<>(); + for (Map.Entry entry : this.pathToApp.entrySet()) { + String appPath = entry.getKey(); + App theApp = entry.getValue(); + theApp.config().setAppPath(appPath); + handler.addServlet(new ServletHolder(new SlackAppServlet(theApp)), appPath); + + if (theApp.config().isOAuthInstallPathEnabled() || theApp.config().isOAuthStartEnabled()) { + if (theApp.config().isDistributedApp()) { + // start + String installPath = appPath + theApp.config().getOauthInstallPath(); + App installPathApp = theApp.toOAuthInstallPathEnabledApp(); + handler.addServlet(new ServletHolder(new SlackOAuthAppServlet(installPathApp)), installPath); + addedOnes.put(installPath, installPathApp); + } else { + log.warn("The app is not ready for handling your Slack App installation URL. Make sure if you set all the necessary values in AppConfig."); + } + } + + if (theApp.config().isOAuthRedirectUriPathEnabled() || theApp.config().isOAuthCallbackEnabled()) { + if (theApp.config().isDistributedApp()) { + // callback + String redirectUriPath = appPath + theApp.config().getOauthRedirectUriPath(); + App oAuthCallbackApp = theApp.toOAuthRedirectUriPathEnabledApp(); + handler.addServlet(new ServletHolder(new SlackOAuthAppServlet(oAuthCallbackApp)), redirectUriPath); + addedOnes.put(redirectUriPath, oAuthCallbackApp); + } else { + log.warn("The app is not ready for handling OAuth callback requests. Make sure if you set all the necessary values in AppConfig."); + } + } + + } + pathToApp.putAll(addedOnes); + server.setHandler(handler); + } + + public void start() throws Exception { + for (App app : pathToApp.values()) { + app.start(); + } + server.start(); + log.info("⚡️ Bolt app is running!"); + server.join(); + } + + public void stop() throws Exception { + for (App app : pathToApp.values()) { + app.stop(); + } + log.info("⚡️ Your Bolt app has stopped..."); + server.stop(); + } + + public ErrorHandler getErrorHandler() { + return errorHandler; + } + + public void setErrorHandler(ErrorHandler errorHandler) { + this.errorHandler = errorHandler; + } + + // ---------------------------------------------------- + // internal methods + // ---------------------------------------------------- + + private static Map toApps(App app, String path) { + Map apps = new HashMap<>(); + apps.put(path, app); + return apps; + } + + private static void removeServerHeader(Server server) { + // https://stackoverflow.com/a/15675075/840108 + for (Connector y : server.getConnectors()) { + for (ConnectionFactory x : y.getConnectionFactories()) { + if (x instanceof HttpConnectionFactory) { + ((HttpConnectionFactory) x).getHttpConfiguration().setSendServerVersion(false); + } + } + } + } + +} diff --git a/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServlet.java b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServlet.java new file mode 100644 index 000000000..aae5c5028 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServlet.java @@ -0,0 +1,46 @@ +package com.slack.api.bolt.servlet; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.request.Request; +import com.slack.api.bolt.response.Response; +import lombok.extern.slf4j.Slf4j; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * The default Servlet that handles incoming requests from the Slack API server. + */ +@Slf4j +public class SlackAppServlet extends HttpServlet { + + private final App app; + private final SlackAppServletAdapter adapter; + + public App getApp() { + return this.app; + } + + public SlackAppServlet(App app) { + this.app = app; + this.adapter = new SlackAppServletAdapter(app.config()); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + Request slackReq = adapter.buildSlackRequest(req); + if (slackReq != null) { + try { + Response slackResp = app.run(slackReq); + adapter.writeResponse(resp, slackResp); + } catch (Exception e) { + log.error("Failed to handle a request - {}", e.getMessage(), e); + resp.setStatus(500); + resp.setContentType("application/json"); + resp.getWriter().write("{\"error\":\"Something is wrong\"}"); + } + } + } +} \ No newline at end of file diff --git a/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServletAdapter.java b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServletAdapter.java new file mode 100644 index 000000000..210abd6ab --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackAppServletAdapter.java @@ -0,0 +1,50 @@ +package com.slack.api.bolt.servlet; + +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.request.Request; +import com.slack.api.bolt.request.RequestHeaders; +import com.slack.api.bolt.response.Response; +import com.slack.api.bolt.util.QueryStringParser; +import com.slack.api.bolt.util.SlackRequestParser; +import lombok.extern.slf4j.Slf4j; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +import static com.slack.api.bolt.servlet.ServletAdapterOps.toHeaderMap; + +/** + * An adapter that converts requests from the Slack API server + * and translates the Response object to the actual Servlet HTTP response. + */ +@Slf4j +public class SlackAppServletAdapter { + + private SlackRequestParser requestParser; + + public SlackAppServletAdapter(AppConfig appConfig) { + this.requestParser = new SlackRequestParser(appConfig); + } + + public Request buildSlackRequest(HttpServletRequest req) throws IOException { + String requestBody = doReadRequestBodyAsString(req); + RequestHeaders headers = new RequestHeaders(toHeaderMap(req)); + SlackRequestParser.HttpRequest rawRequest = SlackRequestParser.HttpRequest.builder() + .requestUri(req.getRequestURI()) + .queryString(QueryStringParser.toMap(req.getQueryString())) + .headers(headers) + .requestBody(requestBody) + .remoteAddress(req.getRemoteAddr()) + .build(); + return requestParser.parse(rawRequest); + } + + protected String doReadRequestBodyAsString(HttpServletRequest req) throws IOException { + return ServletAdapterOps.doReadRequestBodyAsString(req); + } + + public void writeResponse(HttpServletResponse resp, Response slackResp) throws IOException { + ServletAdapterOps.writeResponse(resp, slackResp); + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackOAuthAppServlet.java b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackOAuthAppServlet.java new file mode 100644 index 000000000..040520ccb --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/com/slack/api/bolt/servlet/SlackOAuthAppServlet.java @@ -0,0 +1,46 @@ +package com.slack.api.bolt.servlet; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.request.Request; +import com.slack.api.bolt.response.Response; +import lombok.extern.slf4j.Slf4j; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * The default Slack OAuth flow Servlet. + */ +@Slf4j +public class SlackOAuthAppServlet extends HttpServlet { + + private final App app; + private final SlackAppServletAdapter adapter; + + public App getApp() { + return this.app; + } + + public SlackOAuthAppServlet(App app) { + this.app = app; + this.adapter = new SlackAppServletAdapter(app.config()); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + Request slackReq = adapter.buildSlackRequest(req); + if (slackReq != null) { + try { + Response slackResp = app.run(slackReq); + adapter.writeResponse(resp, slackResp); + } catch (Exception e) { + log.error("Failed to handle a request - {}", e.getMessage(), e); + resp.setStatus(500); + resp.setContentType("application/json"); + resp.getWriter().write("{\"error\":\"Something is wrong\"}"); + } + } + } +} \ No newline at end of file diff --git a/bolt-jakarta-socket-mode/src/test/java/config/Constants.java b/bolt-jakarta-socket-mode/src/test/java/config/Constants.java new file mode 100644 index 000000000..a88fa3007 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/config/Constants.java @@ -0,0 +1,20 @@ +package config; + +public class Constants { + private Constants() { + } + + // Socket Mode + public static final String SLACK_SDK_TEST_SOCKET_MODE_APP_TOKEN = "SLACK_SDK_TEST_SOCKET_MODE_APP_TOKEN"; + public static final String SLACK_SDK_TEST_SOCKET_MODE_BOT_TOKEN = "SLACK_SDK_TEST_SOCKET_MODE_BOT_TOKEN"; + + // -------------------------------------------- + // Enterprise Grid + // Workspace admin user's token in Grid + public static final String SLACK_SDK_TEST_GRID_WORKSPACE_BOT_TOKEN = "SLACK_SDK_TEST_GRID_WORKSPACE_BOT_TOKEN"; + public static final String SLACK_SDK_TEST_GRID_WORKSPACE_USER_TOKEN = "SLACK_SDK_TEST_GRID_WORKSPACE_USER_TOKEN"; + // Org admin user's token in Grid + public static final String SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN = "SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN"; + // For Event Auth & Socket Mode + public static final String SLACK_SDK_TEST_APP_TOKEN = "SLACK_SDK_TEST_APP_TOKEN"; +} diff --git a/bolt-jakarta-socket-mode/src/test/java/config/SlackTestConfig.java b/bolt-jakarta-socket-mode/src/test/java/config/SlackTestConfig.java new file mode 100644 index 000000000..c7152f0d0 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/config/SlackTestConfig.java @@ -0,0 +1,44 @@ +package config; + +import com.slack.api.SlackConfig; +import com.slack.api.rate_limits.metrics.MetricsDatastore; +import com.slack.api.util.http.listener.HttpResponseListener; +import com.slack.api.util.json.GsonFactory; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SlackTestConfig { + + private static final SlackConfig CONFIG = new SlackConfig(); + + private final SlackConfig config; + + public MetricsDatastore getMetricsDatastore() { + return getConfig().getMethodsConfig().getMetricsDatastore(); + } + + private SlackTestConfig(SlackConfig config) { + this.config = config; + CONFIG.getHttpClientResponseHandlers().add(new HttpResponseListener() { + @Override + public void accept(State state) { + String json = GsonFactory.createSnakeCase(CONFIG).toJson(getMetricsDatastore().getAllStats()); + log.debug("--- (MethodsStats) ---\n" + json); + } + }); + } + + static { + CONFIG.setLibraryMaintainerMode(true); + CONFIG.setPrettyResponseLoggingEnabled(true); + CONFIG.setFailOnUnknownProperties(true); + } + + public static SlackTestConfig getInstance() { + return new SlackTestConfig(CONFIG); + } + + public SlackConfig getConfig() { + return config; + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/samples/MultipleConnections.java b/bolt-jakarta-socket-mode/src/test/java/samples/MultipleConnections.java new file mode 100644 index 000000000..7d3315256 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/samples/MultipleConnections.java @@ -0,0 +1,53 @@ +package samples; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.jakarta_socket_mode.SocketModeApp; +import com.slack.api.model.event.AppMentionEvent; +import com.slack.api.model.event.MessageEvent; +import config.Constants; + +public class MultipleConnections { + + public static void main(String[] args) throws Exception { + String appToken = System.getenv(Constants.SLACK_SDK_TEST_SOCKET_MODE_APP_TOKEN); + for (int i = 1; i <= 3; i++) { + final int num = i; + App app = new App(AppConfig.builder() + .singleTeamBotToken(System.getenv(Constants.SLACK_SDK_TEST_SOCKET_MODE_BOT_TOKEN)) + .build()); + app.event(AppMentionEvent.class, (req, ctx) -> { + ctx.asyncClient().reactionsAdd(r -> r + .channel(req.getEvent().getChannel()) + .name(reaction(num)) + .timestamp(req.getEvent().getTs()) + ); + return ctx.ack(); + }); + app.event(MessageEvent.class, (req, ctx) -> ctx.ack()); + + SocketModeApp socketModeApp = new SocketModeApp(appToken, app); + socketModeApp.startAsync(); + socketModeApp.stop(); + socketModeApp.startAsync(); + } + Thread.sleep(Long.MAX_VALUE); + } + + private static String reaction(int num) { + switch (num) { + case 1: + return "one"; + case 2: + return "two"; + case 3: + return "three"; + case 4: + return "four"; + case 5: + return "five"; + default: + return "eyes"; + } + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/samples/OAuth.java b/bolt-jakarta-socket-mode/src/test/java/samples/OAuth.java new file mode 100644 index 000000000..ea379b79c --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/samples/OAuth.java @@ -0,0 +1,38 @@ +package samples; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.jakarta_socket_mode.SocketModeApp; +import com.slack.api.bolt.servlet.SlackAppServer; +import com.slack.api.model.event.AppMentionEvent; + +import java.util.HashMap; +import java.util.Map; + +public class OAuth { + + public static void main(String[] args) throws Exception { + AppConfig appConfig = AppConfig.builder() + .clientId("111.222") + .clientSecret("xxx") + .scope("app_mentions:read,chat:write,commands") + .oauthInstallPath("install") + .oauthRedirectUriPath("oauth_redirect") + .build(); + App app = new App(appConfig); + + app.event(AppMentionEvent.class, (req, ctx) -> { + ctx.say("Hi there!"); + return ctx.ack(); + }); + + String appToken = "xapp-1-A111-111-xxx"; + SocketModeApp socketModeApp = new SocketModeApp(appToken, app); + socketModeApp.startAsync(); + + Map apps = new HashMap<>(); + apps.put("/slack/", new App(appConfig).asOAuthApp(true)); + SlackAppServer oauthSever = new SlackAppServer(apps); + oauthSever.start(); + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/samples/SimpleApp.java b/bolt-jakarta-socket-mode/src/test/java/samples/SimpleApp.java new file mode 100644 index 000000000..733758536 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/samples/SimpleApp.java @@ -0,0 +1,353 @@ +package samples; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.jakarta_socket_mode.SocketModeApp; +import com.slack.api.model.Message; +import com.slack.api.model.block.element.RichTextSectionElement; +import com.slack.api.model.event.AppMentionEvent; +import com.slack.api.model.event.MessageChangedEvent; +import com.slack.api.model.event.MessageDeletedEvent; +import com.slack.api.model.event.MessageEvent; +import com.slack.api.model.view.ViewState; +import config.Constants; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import static com.slack.api.model.block.Blocks.*; +import static com.slack.api.model.block.composition.BlockCompositions.dispatchActionConfig; +import static com.slack.api.model.block.composition.BlockCompositions.plainText; +import static com.slack.api.model.block.element.BlockElements.*; +import static com.slack.api.model.view.Views.*; + +public class SimpleApp { + + public static void main(String[] args) throws Exception { + App app = new App(AppConfig.builder() + .singleTeamBotToken(System.getenv(Constants.SLACK_SDK_TEST_SOCKET_MODE_BOT_TOKEN)) + .build()); + app.use((req, resp, chain) -> { + req.getContext().logger.info(req.getRequestBodyAsString()); + return chain.next(req); + }); + + app.event(AppMentionEvent.class, (req, ctx) -> { + ctx.say("Hi there!"); + return ctx.ack(); + }); + + app.event(MessageEvent.class, (req, ctx) -> { + ctx.asyncClient().reactionsAdd(r -> r + .channel(req.getEvent().getChannel()) + .name("eyes") + .timestamp(req.getEvent().getTs()) + ); + return ctx.ack(); + }); + + app.event(MessageChangedEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageDeletedEvent.class, (req, ctx) -> ctx.ack()); + + app.command("/hello-socket-mode", (req, ctx) -> { + Map eventPayload = new HashMap<>(); + eventPayload.put("something", "great"); + Message.Metadata metadata = Message.Metadata.builder() + .eventType("foo") + .eventPayload(eventPayload) + .build(); + ctx.respond(r -> r.responseType("in_channel").text("hi!").metadata(metadata)); + ctx.asyncClient().viewsOpen(r -> r + .triggerId(req.getContext().getTriggerId()) + .view(view(v -> v + .type("modal") + .callbackId("test-view") + .title(viewTitle(vt -> vt.type("plain_text").text("Modal by Global Shortcut"))) + .close(viewClose(vc -> vc.type("plain_text").text("Close"))) + .submit(viewSubmit(vs -> vs.type("plain_text").text("Submit"))) + .blocks(asBlocks( + input(input -> input + .blockId("agenda-block") + .element(plainTextInput(pti -> pti.actionId("agenda-action").multiline(true))) + .label(plainText(pt -> pt.text("Detailed Agenda").emoji(true))) + ), + // Note that this block element requires files:read scope + input(input -> input + .blockId("files-block") + .element(fileInput(fi -> fi.actionId("files-action"))) + .label(plainText(pt -> pt.text("Attached files").emoji(true))) + ), + input(input -> input + .blockId("email-block") + .element(emailTextInput(pti -> pti.actionId("email-action"))) + .label(plainText(pt -> pt.text("Email Address").emoji(true))) + ), + input(input -> input + .blockId("url-block") + .element(urlTextInput(pti -> pti.actionId("url-action"))) + .label(plainText(pt -> pt.text("URL").emoji(true))) + ), + input(input -> input + .blockId("number-block") + .element(numberInput(pti -> pti.actionId("number-action"))) + .label(plainText(pt -> pt.text("Budget").emoji(true))) + ), + input(input -> input + .blockId("date-block") + .element(datePicker(pti -> pti.actionId("date-action"))) + .label(plainText(pt -> pt.text("Date").emoji(true))) + ), + input(input -> input + .blockId("time-block") + .element(timePicker(pti -> pti.actionId("time-action").timezone("America/Los_Angeles"))) + .label(plainText(pt -> pt.text("Time").emoji(true))) + ), + input(input -> input + .blockId("date-time-block") + .element(datetimePicker(pti -> pti.actionId("date-time-action"))) + .label(plainText(pt -> pt.text("Date Time").emoji(true))) + ), + input(input -> input + .blockId("rich-text-block") + .element(richTextInput(pti -> pti.actionId("rich-text-action") + .initialValue(richText(rt -> rt.blockId("b").elements(Arrays.asList( + richTextList(rtl -> rtl.style("bullet").elements(Arrays.asList( + richTextSection(rtl1 -> rtl1.elements(Arrays.asList( + RichTextSectionElement.Text.builder() + .text("Hey!") + .style(RichTextSectionElement.TextStyle.builder().bold(true).build()) + .build() + ))), + richTextSection(rtl2 -> rtl2.elements(Arrays.asList( + RichTextSectionElement.Text.builder().text("What's up?").build() + ))) + ))) + )))) + .dispatchActionConfig(dispatchActionConfig(dc -> dc.triggerActionsOn(Arrays.asList("on_character_entered")))) + )) + .dispatchAction(true) + .label(plainText(pt -> pt.text("Rich Text").emoji(true))) + ) + )) + ))); + return ctx.ack(); + }); + + app.blockAction("rich-text-action", (req, ctx) -> { + ctx.logger.info("state values: {}", req.getPayload().getView().getState().getValues()); + ctx.logger.info("action[0]: {}", req.getPayload().getActions().get(0)); + return ctx.ack(); + }); + + app.viewSubmission("test-view", (req, ctx) -> { + ViewState.Value time = req.getPayload().getView().getState().getValues().get("time-block").get("time-action"); + assert time.getTimezone().equals("America/Los_Angeles"); + ViewState.Value richText = req.getPayload().getView().getState().getValues().get("rich-text-block").get("rich-text-action"); + assert richText.getRichTextValue().getElements().size() > 0; + return ctx.ack(); + }); + + app.messageShortcut("socket-mode-message-shortcut", (req, ctx) -> { + ctx.respond("It works!"); + return ctx.ack(); + }); + + /* Example App Manifest +{ + "display_information": { + "name": "manifest-test-app-2" + }, + "features": { + "bot_user": { + "display_name": "test-bot", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "commands", + "chat:write", + "app_mentions:read" + ] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "app_mention", + "function_executed" + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": true, + "socket_mode_enabled": true, + "token_rotation_enabled": false, + "hermes_app_type": "remote", + "function_runtime": "remote" + }, + "functions": { + "hello": { + "title": "Hello", + "description": "Hello world!", + "input_parameters": { + "amount": { + "type": "number", + "title": "Amount", + "description": "How many do you need?", + "is_required": false, + "hint": "How many do you need?", + "name": "amount", + "maximum": 10, + "minimum": 1 + }, + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "Who to send it", + "is_required": true, + "hint": "Select a user in the workspace", + "name": "user_id" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Whatever you want to tell", + "is_required": false, + "hint": "up to 100 characters", + "name": "message", + "maxLength": 100, + "minLength": 1 + } + }, + "output_parameters": { + "amount": { + "type": "number", + "title": "Amount", + "description": "How many do you need?", + "is_required": false, + "hint": "How many do you need?", + "name": "amount", + "maximum": 10, + "minimum": 1 + }, + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "Who to send it", + "is_required": true, + "hint": "Select a user in the workspace", + "name": "user_id" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Whatever you want to tell", + "is_required": false, + "hint": "up to 100 characters", + "name": "message", + "maxLength": 100, + "minLength": 1 + } + } + } + } +} + */ + + // app.event(FunctionExecutedEvent.class, (req, ctx) -> { + // app.function("hello", (req, ctx) -> { + app.function(Pattern.compile("^he.+$"), (req, ctx) -> { + ctx.logger.info("req: {}", req); + ctx.client().chatPostMessage(r -> r + .channel(req.getEvent().getInputs().get("user_id").asString()) + .text("hey!") + .blocks(asBlocks(actions(a -> a.blockId("b").elements(asElements( + button(b -> b.actionId("remote-function-button-success").value("clicked").text(plainText("block_actions success"))), + button(b -> b.actionId("remote-function-button-error").value("clicked").text(plainText("block_actions error"))), + button(b -> b.actionId("remote-function-modal").value("clicked").text(plainText("modal view"))) + ))))) + ); + return ctx.ack(); + }); + + app.blockAction("remote-function-button-success", (req, ctx) -> { + Map outputs = new HashMap<>(); + outputs.put("user_id", req.getPayload().getFunctionData().getInputs().get("user_id").asString()); + ctx.client().functionsCompleteSuccess(r -> r + .functionExecutionId(req.getPayload().getFunctionData().getExecutionId()) + .outputs(outputs) + ); + ctx.client().chatUpdate(r -> r + .channel(req.getPayload().getContainer().getChannelId()) + .ts(req.getPayload().getContainer().getMessageTs()) + .text("Thank you!") + ); + return ctx.ack(); + }); + app.blockAction("remote-function-button-error", (req, ctx) -> { + ctx.client().functionsCompleteError(r -> r + .functionExecutionId(req.getPayload().getFunctionData().getExecutionId()) + .error("test error!") + ); + ctx.client().chatUpdate(r -> r + .channel(req.getPayload().getContainer().getChannelId()) + .ts(req.getPayload().getContainer().getMessageTs()) + .text("Thank you!") + ); + return ctx.ack(); + }); + app.blockAction("remote-function-modal", (req, ctx) -> { + ctx.client().viewsOpen(r -> r + .triggerId(req.getPayload().getInteractivity().getInteractivityPointer()) + .view(view(v -> v + .type("modal") + .callbackId("remote-function-view") + .title(viewTitle(vt -> vt.type("plain_text").text("Remote Function test"))) + .close(viewClose(vc -> vc.type("plain_text").text("Close"))) + .submit(viewSubmit(vs -> vs.type("plain_text").text("Submit"))) + .notifyOnClose(true) + .blocks(asBlocks(input(input -> input + .blockId("text-block") + .element(plainTextInput(pti -> pti.actionId("text-action").multiline(true))) + .label(plainText(pt -> pt.text("Text").emoji(true))) + ))) + ))); + ctx.client().chatUpdate(r -> r + .channel(req.getPayload().getContainer().getChannelId()) + .ts(req.getPayload().getContainer().getMessageTs()) + .text("Thank you!") + ); + return ctx.ack(); + }); + + app.viewSubmission("remote-function-view", (req, ctx) -> { + Map outputs = new HashMap<>(); + outputs.put("user_id", ctx.getRequestUserId()); + ctx.client().functionsCompleteSuccess(r -> r + .token(req.getPayload().getBotAccessToken()) + .functionExecutionId(req.getPayload().getFunctionData().getExecutionId()) + .outputs(outputs) + ); + return ctx.ack(); + }); + app.viewClosed("remote-function-view", (req, ctx) -> { + Map outputs = new HashMap<>(); + outputs.put("user_id", ctx.getRequestUserId()); + ctx.client().functionsCompleteSuccess(r -> r + .token(req.getPayload().getBotAccessToken()) + .functionExecutionId(req.getPayload().getFunctionData().getExecutionId()) + .outputs(outputs) + ); + return ctx.ack(); + }); + + String appToken = System.getenv(Constants.SLACK_SDK_TEST_SOCKET_MODE_APP_TOKEN); + SocketModeApp socketModeApp = new SocketModeApp(appToken, app); + socketModeApp.start(); + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/samples/UnfurlingApp.java b/bolt-jakarta-socket-mode/src/test/java/samples/UnfurlingApp.java new file mode 100644 index 000000000..15c9592e4 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/samples/UnfurlingApp.java @@ -0,0 +1,73 @@ +package samples; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.jakarta_socket_mode.SocketModeApp; +import com.slack.api.methods.request.chat.ChatUnfurlRequest; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.event.LinkSharedEvent; +import com.slack.api.model.event.MessageChangedEvent; +import com.slack.api.model.event.MessageEvent; +import com.slack.api.util.json.GsonFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.slack.api.model.block.Blocks.actions; +import static com.slack.api.model.block.Blocks.asBlocks; +import static com.slack.api.model.block.composition.BlockCompositions.plainText; +import static com.slack.api.model.block.element.BlockElements.asElements; +import static com.slack.api.model.block.element.BlockElements.button; + +public class UnfurlingApp { + + public static void main(String[] args) throws Exception { + String appToken = System.getenv("SLACK_APP_TOKEN"); + String botToken = System.getenv("SLACK_BOT_TOKEN"); + App app = new App(AppConfig.builder().singleTeamBotToken(botToken).build()); + app.use((req, resp, chain) -> { + req.getContext().logger.info(req.getRequestBodyAsString()); + return chain.next(req); + }); + + app.event(MessageEvent.class, (payload, ctx) -> ctx.ack()); + app.event(MessageChangedEvent.class, (payload, ctx) -> ctx.ack()); + + app.event(LinkSharedEvent.class, (payload, ctx) -> { + app.executorService().submit(() -> { + try { + Map unfurls = new HashMap<>(); + for (LinkSharedEvent.Link link : payload.getEvent().getLinks()) { + ChatUnfurlRequest.UnfurlDetail unfurl = new ChatUnfurlRequest.UnfurlDetail(); + // unfurl.setTitle("Collaborate & Create Amazing Graphic Design for Free"); + // unfurl.setText("text text text"); + List blocks = asBlocks(actions(a -> a.blockId("b").elements(asElements( + button(b -> b.actionId("a").value("clicked").text(plainText("Click this!"))) + )))); + unfurl.setBlocks(blocks); + ctx.logger.info(GsonFactory.createSnakeCase().toJson(unfurl)); + unfurls.put(link.getUrl(), unfurl); + } + ctx.client().chatUnfurl(r -> r + .channel(payload.getEvent().getChannel()) + .ts(payload.getEvent().getMessageTs()) + .source(payload.getEvent().getSource()) + .unfurls(unfurls) + ); + } catch (Exception e) { + ctx.logger.error("Failed to unfurl the links", e); + } + }); + return ctx.ack(); + }); + + app.blockAction("a", (req, ctx) -> { + ctx.logger.info(req.getRequestBodyAsString()); + return ctx.ack(); + }); + + SocketModeApp socketModeApp = new SocketModeApp(appToken, app); + socketModeApp.start(); + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/samples/UserChangeEventApp.java b/bolt-jakarta-socket-mode/src/test/java/samples/UserChangeEventApp.java new file mode 100644 index 000000000..de4c7c70d --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/samples/UserChangeEventApp.java @@ -0,0 +1,39 @@ +package samples; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.jakarta_socket_mode.SocketModeApp; +import com.slack.api.model.event.UserChangeEvent; +import com.slack.api.model.event.UserHuddleChangedEvent; +import com.slack.api.model.event.UserProfileChangedEvent; +import com.slack.api.model.event.UserStatusChangedEvent; +import config.Constants; + +public class UserChangeEventApp { + + public static void main(String[] args) throws Exception { + App app = new App(AppConfig.builder() + .singleTeamBotToken(System.getenv(Constants.SLACK_SDK_TEST_SOCKET_MODE_BOT_TOKEN)) + .build()); + app.use((req, resp, chain) -> { + req.getContext().logger.info(req.getRequestBodyAsString()); + return chain.next(req); + }); + + app.event(UserChangeEvent.class, (payload, ctx) -> { + return ctx.ack(); + }); + app.event(UserProfileChangedEvent.class, (payload, ctx) -> { + return ctx.ack(); + }); + app.event(UserStatusChangedEvent.class, (payload, ctx) -> { + return ctx.ack(); + }); + app.event(UserHuddleChangedEvent.class, (payload, ctx) -> { + return ctx.ack(); + }); + String appToken = System.getenv(Constants.SLACK_SDK_TEST_SOCKET_MODE_APP_TOKEN); + SocketModeApp socketModeApp = new SocketModeApp(appToken, app); + socketModeApp.start(); + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/test_locally/SocketModeAppTest.java b/bolt-jakarta-socket-mode/src/test/java/test_locally/SocketModeAppTest.java new file mode 100644 index 000000000..d54383704 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/test_locally/SocketModeAppTest.java @@ -0,0 +1,100 @@ +package test_locally; + +import com.slack.api.Slack; +import com.slack.api.SlackConfig; +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.jakarta_socket_mode.SocketModeApp; +import com.slack.api.bolt.response.Response; +import com.slack.api.model.event.AppMentionEvent; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import util.socket_mode.MockWebApiServer; +import util.socket_mode.MockWebSocketServer; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class SocketModeAppTest { + + static final String VALID_BOT_TOKEN = "xoxb-valid-123123123123123123123123123123123123"; + static final String VALID_APP_TOKEN = "xapp-valid-123123123123123123123123123123123123"; + + MockWebSocketServer wsServer = new MockWebSocketServer(); + MockWebApiServer webApiServer = new MockWebApiServer(); + SlackConfig config = new SlackConfig(); + Slack slack = Slack.getInstance(config); + + @Before + public void setup() throws Exception { + webApiServer.start(); + wsServer.start(); + config.setMethodsEndpointUrlPrefix(webApiServer.getMethodsEndpointPrefix()); + } + + @After + public void tearDown() throws Exception { + webApiServer.stop(); + wsServer.stop(); + } + + // ------------------------------------------------- + // Default implementation + // ------------------------------------------------- + + @Test + public void payloadHandling() throws Exception { + App app = new App(AppConfig.builder() + .slack(slack) + .singleTeamBotToken(VALID_BOT_TOKEN) + .build()); + AtomicBoolean commandCalled = new AtomicBoolean(false); + AtomicBoolean actionCalled = new AtomicBoolean(false); + AtomicBoolean eventCalled = new AtomicBoolean(false); + app.command("/hi-socket-mode", (req, ctx) -> { + commandCalled.set(true); + return ctx.ack("Hello!"); + }); + app.blockAction("a", (req, ctx) -> { + actionCalled.set(true); + return Response.builder().body("Thanks").build(); + }); + app.event(AppMentionEvent.class, (req, ctx) -> { + eventCalled.set(ctx.getRetryNum() == 2 && ctx.getRetryReason().equals("timeout")); + return ctx.ack(); + }); + SocketModeApp socketModeApp = new SocketModeApp(VALID_APP_TOKEN, app); + socketModeApp.startAsync(); + try { + Thread.sleep(3_000L); + assertTrue(commandCalled.get()); + assertTrue(actionCalled.get()); + assertTrue(eventCalled.get()); + } finally { + socketModeApp.stop(); + } + } + + @Test + public void issue_937_it_should_not_be_connected_before_start_method_call() throws Exception { + App app = new App(AppConfig.builder() + .slack(slack) + .singleTeamBotToken(VALID_BOT_TOKEN) + .build()); + SocketModeApp socketModeApp = new SocketModeApp(VALID_APP_TOKEN, app); + try { + assertTrue(socketModeApp.isClientStopped()); + assertNull(socketModeApp.getClient()); + + // Check again to make sure if the auto-connection does not work in this scenario + Thread.sleep(3_000L); + assertTrue(socketModeApp.isClientStopped()); + assertNull(socketModeApp.getClient()); + } finally { + socketModeApp.close(); + } + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/test_with_remote_apis/EventsApiApp.java b/bolt-jakarta-socket-mode/src/test/java/test_with_remote_apis/EventsApiApp.java new file mode 100644 index 000000000..28687d206 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/test_with_remote_apis/EventsApiApp.java @@ -0,0 +1,25 @@ +package test_with_remote_apis; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.jakarta_socket_mode.SocketModeApp; +import com.slack.api.model.event.*; +import util.ResourceLoader; + +public class EventsApiApp { + + public static void main(String[] args) throws Exception { + AppConfig appConfig = ResourceLoader.loadAppConfig(); + App app = new App(appConfig); + SocketModeApp socketModeClient = new SocketModeApp(app); + app.event(MessageEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageBotEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageChangedEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageChannelJoinEvent.class, (req, ctx) -> ctx.ack()); + app.event(ChannelSharedEvent.class, (req, ctx) -> ctx.ack()); + app.event(ChannelJoinedEvent.class, (req, ctx) -> ctx.ack()); + app.event(ChannelIdChangedEvent.class, (req, ctx) -> ctx.ack()); + app.event(MemberJoinedChannelEvent.class, (req, ctx) -> ctx.ack()); + socketModeClient.start(); + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/test_with_remote_apis/EventsApiTest.java b/bolt-jakarta-socket-mode/src/test/java/test_with_remote_apis/EventsApiTest.java new file mode 100644 index 000000000..5eadf7b6f --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/test_with_remote_apis/EventsApiTest.java @@ -0,0 +1,967 @@ +package test_with_remote_apis; + +import com.google.gson.Gson; +import com.slack.api.Slack; +import com.slack.api.SlackConfig; +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.jakarta_socket_mode.SocketModeApp; +import com.slack.api.methods.request.files.FilesUploadRequest; +import com.slack.api.methods.response.apps.event.authorizations.AppsEventAuthorizationsListResponse; +import com.slack.api.methods.response.auth.AuthTestResponse; +import com.slack.api.methods.response.chat.ChatDeleteResponse; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.methods.response.chat.ChatUpdateResponse; +import com.slack.api.methods.response.conversations.*; +import com.slack.api.methods.response.dnd.DndEndSnoozeResponse; +import com.slack.api.methods.response.dnd.DndSetSnoozeResponse; +import com.slack.api.methods.response.files.FilesDeleteResponse; +import com.slack.api.methods.response.files.FilesUploadResponse; +import com.slack.api.methods.response.pins.PinsAddResponse; +import com.slack.api.methods.response.pins.PinsRemoveResponse; +import com.slack.api.methods.response.reactions.ReactionsAddResponse; +import com.slack.api.methods.response.reactions.ReactionsRemoveResponse; +import com.slack.api.methods.response.stars.StarsAddResponse; +import com.slack.api.methods.response.stars.StarsRemoveResponse; +import com.slack.api.methods.response.usergroups.UsergroupsCreateResponse; +import com.slack.api.methods.response.usergroups.users.UsergroupsUsersUpdateResponse; +import com.slack.api.model.block.SectionBlock; +import com.slack.api.model.block.composition.MarkdownTextObject; +import com.slack.api.model.event.*; +import com.slack.api.util.json.GsonFactory; +import config.Constants; +import config.SlackTestConfig; +import lombok.Data; +import org.junit.Before; +import org.junit.Test; +import util.ResourceLoader; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class EventsApiTest { + + Map configValues = new HashMap<>(); + SlackConfig recorderSlackConfig = SlackTestConfig.getInstance().getConfig(); + SlackConfig slackConfig = new SlackConfig(); + Slack recorderSlack = null; + Slack slack = null; + AppConfig appConfig = ResourceLoader.loadAppConfig(); + String appToken = null; + Gson gson = null; + + private void waitForSlackAppConnection() throws IOException, InterruptedException { + Thread.sleep(500L); + } + + @Before + public void setup() { + recorderSlackConfig.setPrettyResponseLoggingEnabled(true); + recorderSlack = Slack.getInstance(recorderSlackConfig); + slack = Slack.getInstance(slackConfig); + configValues = ResourceLoader.loadValues(); + appToken = configValues.get("appToken"); + appConfig.setSigningSecret(configValues.get("signingSecret")); + appConfig.setSingleTeamBotToken(configValues.get("singleTeamBotToken")); + gson = GsonFactory.createSnakeCase(recorderSlackConfig); + } + + @Data + public static class ChannelTestState { + private boolean channelCreated; + private boolean memberJoinedChannel; + private boolean memberLeftChannel; + private boolean channelLeft; + private boolean pinAdded; + private boolean pinRemoved; + private boolean channelRenamed; + private boolean channelShared; + private boolean channelUnshared; + private boolean channelArchive; + private boolean channelUnarchive; + private boolean appMention; + private boolean linkShared; + private boolean message; + private boolean reactionAdded; + private boolean reactionRemoved; + private boolean starAdded; + private boolean starRemoved; + private boolean fileCreated; + private boolean fileDeleted; + private boolean filePublic; + private boolean fileShared; + private boolean fileUnshared; + + public boolean isAllDone() { + return channelCreated + && memberJoinedChannel && memberLeftChannel + && channelLeft + && channelRenamed + // TODO + // && channelShared && channelUnshared + && channelArchive && channelUnarchive + && pinAdded && pinRemoved + && appMention && message + // TODO + // && linkShared + && reactionAdded && reactionRemoved + && starAdded && starRemoved + && fileCreated && fileDeleted && filePublic && fileShared && fileUnshared; + } + } + + @Test + public void publicChannelsAndInteractions() throws Exception { + + App app = new App(appConfig); + + String publicChannelId = null; + String botToken = appConfig.getSingleTeamBotToken(); + String userToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_WORKSPACE_USER_TOKEN); + + SocketModeApp socketModeClient = new SocketModeApp(appToken, app); + ChannelTestState state = new ChannelTestState(); + try { + + // ---------------------------- + // Public channels + // ---------------------------- + // channel_created + app.event(ChannelCreatedEvent.class, (req, ctx) -> { + state.setChannelCreated(true); + return ctx.ack(); + }); + + // member_joined_channel + app.event(MemberJoinedChannelEvent.class, (req, ctx) -> { + state.setMemberJoinedChannel(true); + return ctx.ack(); + }); + + // member_left_channel + app.event(MemberLeftChannelEvent.class, (req, ctx) -> { + state.setMemberLeftChannel(true); + return ctx.ack(); + }); + + // pin_added + app.event(PinAddedEvent.class, (req, ctx) -> { + state.setPinAdded(true); + return ctx.ack(); + }); + // pin_removed + app.event(PinRemovedEvent.class, (req, ctx) -> { + state.setPinRemoved(true); + return ctx.ack(); + }); + // channel_rename + app.event(ChannelRenameEvent.class, (req, ctx) -> { + state.setChannelRenamed(true); + return ctx.ack(); + }); + // channel_left + app.event(ChannelLeftEvent.class, (req, ctx) -> { + state.setChannelLeft(true); + return ctx.ack(); + }); + // channel_shared + app.event(ChannelSharedEvent.class, (req, ctx) -> { + state.setChannelShared(true); + return ctx.ack(); + }); + // channel_unshared + app.event(ChannelUnsharedEvent.class, (req, ctx) -> { + state.setChannelUnshared(true); + return ctx.ack(); + }); + // channel_archive + app.event(ChannelArchiveEvent.class, (req, ctx) -> { + state.setChannelArchive(true); + return ctx.ack(); + }); + // channel_unarchive + app.event(ChannelUnarchiveEvent.class, (req, ctx) -> { + state.setChannelUnarchive(true); + return ctx.ack(); + }); + + // ---------------------------- + // Interaction + // ---------------------------- + // app_mention + app.event(AppMentionEvent.class, (req, ctx) -> { + state.setAppMention(true); + return ctx.ack(); + }); + + // link_shared + app.event(LinkSharedEvent.class, (req, ctx) -> { + state.setLinkShared(true); + return ctx.ack(); + }); + + // message + app.event(MessageEvent.class, (req, ctx) -> { + state.setMessage(true); + return ctx.ack(); + }); + + // reaction_added + app.event(ReactionAddedEvent.class, (req, ctx) -> { + state.setReactionAdded(true); + return ctx.ack(); + }); + // reaction_removed + app.event(ReactionRemovedEvent.class, (req, ctx) -> { + state.setReactionRemoved(true); + return ctx.ack(); + }); + + // star_added + app.event(StarAddedEvent.class, (req, ctx) -> { + state.setStarAdded(true); + return ctx.ack(); + }); + // star_removed + app.event(StarRemovedEvent.class, (req, ctx) -> { + state.setStarRemoved(true); + return ctx.ack(); + }); + + // ---------------------------- + // Files + // ---------------------------- + // file_created + app.event(FileCreatedEvent.class, (req, ctx) -> { + state.setFileCreated(true); + return ctx.ack(); + }); + // file_deleted + app.event(FileDeletedEvent.class, (req, ctx) -> { + state.setFileDeleted(true); + return ctx.ack(); + }); + // file_public + app.event(FilePublicEvent.class, (req, ctx) -> { + state.setFilePublic(true); + return ctx.ack(); + }); + // file_shared + app.event(FileSharedEvent.class, (req, ctx) -> { + state.setFileShared(true); + return ctx.ack(); + }); + // file_unshared + app.event(FileUnsharedEvent.class, (req, ctx) -> { + state.setFileUnshared(true); + return ctx.ack(); + }); + + // ------------------------------------------------------------------------------------ + + socketModeClient.startAsync(); + + waitForSlackAppConnection(); + + // ------------------------------------------------------------------------------------ + + // channel_created + ConversationsCreateResponse publicChannel = + slack.methods(botToken).conversationsCreate(r -> r.name("test-" + System.currentTimeMillis()).isPrivate(false)); + assertNull(publicChannel.getError()); + + publicChannelId = publicChannel.getChannel().getId(); + AuthTestResponse botAuthTest = slack.methods().authTest(r -> r.token(botToken)); + String botUserId = botAuthTest.getUserId(); + { + final String channelId = publicChannelId; + + // member_joined_channel + ConversationsJoinResponse joining = slack.methods(userToken).conversationsJoin(r -> r.channel(channelId)); + assertNull(joining.getError()); + // member_left_channel + // channel_left + ConversationsLeaveResponse leaving = slack.methods(userToken).conversationsLeave(r -> r.channel(channelId)); + assertNull(leaving.getError()); + + // join again for the further steps + joining = slack.methods(userToken).conversationsJoin(r -> r.channel(channelId)); + assertNull(joining.getError()); + + // app_mention + String mentionText = "<@" + botUserId + "> Hello!"; + ChatPostMessageResponse mention = slack.methods(userToken).chatPostMessage(r -> + r.text(mentionText).channel(channelId)); + assertNull(mention.getError()); + ChatUpdateResponse editedMention = slack.methods(userToken).chatUpdate(r -> r + .channel(channelId) + .ts(mention.getTs()) + .text(mentionText + "(edited)") + ); + assertNull(editedMention.getError()); + + // app_mention with blocks + ChatPostMessageResponse mention2 = slack.methods(userToken).chatPostMessage(r -> + r.text(mentionText).blocks(Arrays.asList( + SectionBlock.builder() + .blockId("block-id-value") + // this needs to be a mrkdwn-type text object + .text(MarkdownTextObject.builder().text(mentionText).build()) + .build() + )).channel(channelId)); + assertNull(mention2.getError()); + + // link_shared + // NOTE: Add "www.youtube.com" to Event Subscriptions > App unfurl domains + ChatPostMessageResponse linkShared = slack.methods(userToken).chatPostMessage(r -> r + .channel(channelId) + .text("") + .asUser(true) + .unfurlLinks(true) + .unfurlMedia(true) + ); + assertNull(linkShared.getError()); + + String linkMessageTs = linkShared.getTs(); + + // reaction_added + ReactionsAddResponse reactionCreation = + slack.methods(userToken).reactionsAdd(r -> r.channel(channelId).timestamp(linkMessageTs).name("eyes")); + assertNull(reactionCreation.getError()); + // reaction_removed + ReactionsRemoveResponse reactionRemoval = + slack.methods(userToken).reactionsRemove(r -> r.channel(channelId).timestamp(linkMessageTs).name("eyes")); + assertNull(reactionRemoval.getError()); + + // star_added + StarsAddResponse starCreation = slack.methods(userToken).starsAdd(r -> r.channel(channelId).timestamp(linkMessageTs)); + assertNull(starCreation.getError()); + // star_removed + StarsRemoveResponse starRemoval = slack.methods(userToken).starsRemove(r -> r.channel(channelId).timestamp(linkMessageTs)); + assertNull(starRemoval.getError()); + + // file_created + // file_public + // file_shared + FilesUploadResponse fileUpload = slack.methods(botToken).filesUpload(r -> r + .title("Sample Text") + .content("This is a text file.") + .channels(Arrays.asList(channelId)) + .initialComment("This is a test.") + .filename("sample.txt") + ); + assertNull(fileUpload.getError()); + + String fileId = fileUpload.getFile().getId(); + String fileShareTs = fileUpload.getFile().getShares().getPublicChannels().get(channelId).get(0).getTs(); + + // file_unshared + ChatDeleteResponse fileUnsharing = slack.methods(botToken).chatDelete(r -> r.channel(channelId).ts(fileShareTs)); + assertNull(fileUnsharing.getError()); + + // file_deleted + FilesDeleteResponse fileDeletion = slack.methods(botToken).filesDelete(r -> r.file(fileId)); + assertNull(fileDeletion.getError()); + + // pin_added + String pinnedMessageTs = slack.methods(botToken).chatPostMessage(r -> r + .channel(channelId) + .text("This is really *important*.") + // TODO + //.blocks(SampleObjects.Blocks) + ).getTs(); + // Adding to pinned items needs to be done by another user. + PinsAddResponse pinCreation = slack.methods(userToken).pinsAdd(r -> r.channel(channelId).timestamp(pinnedMessageTs)); + assertNull(pinCreation.getError()); + // pin_removed + // Removing from pinned items needs to be done by another user. + PinsRemoveResponse pinRemoval = slack.methods(userToken).pinsRemove(r -> r.channel(channelId).timestamp(pinnedMessageTs)); + assertNull(pinRemoval.getError()); + + // channel_rename + ConversationsRenameResponse renaming = + slack.methods(botToken).conversationsRename(r -> r.channel(channelId).name(publicChannel.getChannel().getName() + "-2")); + assertNull(renaming.getError()); + + // TODO + /* + String teamId = botAuthTest.getTeamId(); + String orgAdminUserToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN); + + // channel_shared + List workspaces = slack.methods(orgAdminUserToken).adminTeamsList(r -> r.limit(3)).getTeams(); + List teamIds = workspaces.stream().map(t -> t.getId()).collect(toList()); + if (!teamIds.contains(teamId)) { + teamIds.add(teamId); + } + AdminConversationsSetTeamsResponse sharing = + slack.methods(orgAdminUserToken).adminConversationsSetTeams(r -> r + .teamId(teamId) + .channelId(channelId) + .targetTeamIds(teamIds) + ); + assertNull(sharing.getError()); + + Thread.sleep(5000L); + + // channel_unshared + teamIds.clear(); + teamIds.add(teamId); + AdminConversationsSetTeamsResponse unsharing = + slack.methods(orgAdminUserToken).adminConversationsSetTeams(r -> r + //.teamId(teamId) + .targetTeamIds(teamIds) + .channelId(channelId)); + assertNull(unsharing.getError()); + Thread.sleep(5000L); + + */ + + // join for sure before archiving + ConversationsJoinResponse rejoining = slack.methods(userToken).conversationsJoin(r -> r.channel(channelId)); + assertNull(rejoining.getError()); + // channel_archive + ConversationsArchiveResponse archive = slack.methods(userToken).conversationsArchive(r -> r.channel(channelId)); + assertNull(archive.getError()); + // channel_unarchive + ConversationsUnarchiveResponse unarchive = slack.methods(userToken).conversationsUnarchive(r -> r.channel(channelId)); + assertNull(unarchive.getError()); + } + + // ------------------------------------------------------------------------------------ + + long waitTime = 0; + while (!state.isAllDone() && waitTime < 50_000L) { + long sleepTime = 100L; + Thread.sleep(sleepTime); + waitTime += sleepTime; + } + assertTrue(state.toString(), state.isAllDone()); + + } finally { + socketModeClient.stop(); + + if (publicChannelId != null) { + String channelId = publicChannelId; + slack.methods(botToken).conversationsArchive(r -> r.channel(channelId)); + } + } + } + + // ---------------------------- + // Private channels + // ---------------------------- + + @Data + public static class GroupTestState { + private boolean groupOpen; + private boolean groupRename; + private boolean groupLeft; + private boolean groupClose; + private boolean groupArchive; + private boolean groupUnarchive; + + public boolean isAllDone() { + return groupRename + && groupLeft + //&& groupOpen && groupClose + && groupArchive + //&& groupUnarchive + ; + } + } + + @Test + public void privateChannels() throws Exception { + + App app = new App(appConfig); + + String privateChannelId = null; + String botToken = appConfig.getSingleTeamBotToken(); + + SocketModeApp socketModeClient = new SocketModeApp(appToken, app); + GroupTestState state = new GroupTestState(); + try { + + // group_open + app.event(GroupOpenEvent.class, (req, ctx) -> { + state.setGroupOpen(true); + return ctx.ack(); + }); + // group_rename + app.event(GroupRenameEvent.class, (req, ctx) -> { + state.setGroupRename(true); + return ctx.ack(); + }); + // group_left + app.event(GroupLeftEvent.class, (req, ctx) -> { + state.setGroupLeft(true); + return ctx.ack(); + }); + // group_close + app.event(GroupCloseEvent.class, (req, ctx) -> { + state.setGroupClose(true); + return ctx.ack(); + }); + // group_archive + app.event(GroupArchiveEvent.class, (req, ctx) -> { + state.setGroupArchive(true); + return ctx.ack(); + }); + // group_unarchive + app.event(GroupUnarchiveEvent.class, (req, ctx) -> { + state.setGroupUnarchive(true); + return ctx.ack(); + }); + app.event(MemberJoinedChannelEvent.class, (req, ctx) -> ctx.ack()); + app.event(MemberLeftChannelEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageChannelJoinEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageChannelArchiveEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageChannelUnarchiveEvent.class, (req, ctx) -> ctx.ack()); + app.event(MessageChannelNameEvent.class, (req, ctx) -> ctx.ack()); + + // ------------------------------------------------------------------------------------ + + socketModeClient.startAsync(); + + waitForSlackAppConnection(); + + // ------------------------------------------------------------------------------------ + + String userToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN); + String userId = slack.methods().authTest(r -> r.token(userToken)).getUserId(); + + // group_open + ConversationsCreateResponse privateChannel = slack.methods(botToken).conversationsCreate(r -> r + .name("private-test-" + System.currentTimeMillis()) + .isPrivate(true) + ); + assertNull(privateChannel.getError()); + + privateChannelId = privateChannel.getChannel().getId(); + { + + final String channelId = privateChannelId; + // group_rename + ConversationsRenameResponse renaming = + slack.methods(botToken).conversationsRename(r -> r.channel(channelId).name(privateChannel.getChannel().getName() + "-2")); + assertNull(renaming.getError()); + + // group_close + // group_archive + ConversationsArchiveResponse archive = slack.methods(botToken).conversationsArchive(r -> r.channel(channelId)); + assertNull(archive.getError()); + // group_unarchive + ConversationsUnarchiveResponse unarchive = slack.methods(botToken).conversationsUnarchive(r -> r.channel(channelId)); + assertNull(unarchive.getError()); + + // group_left + ConversationsInviteResponse invitation = + slack.methods(botToken).conversationsInvite(r -> r.channel(channelId).users(Arrays.asList(userId))); + assertNull(invitation.getError()); + ConversationsKickResponse removal = + slack.methods(botToken).conversationsKick(r -> r.channel(channelId).user(userId)); + assertNull(removal.getError()); + + } + + // ------------------------------------------------------------------------------------ + long waitTime = 0; + while (!state.isAllDone() && waitTime < 10_000L) { + long sleepTime = 100L; + Thread.sleep(sleepTime); + waitTime += sleepTime; + } + assertTrue(state.toString(), state.isAllDone()); + + // for accepting other events + Thread.sleep(5_000L); + + } finally { + socketModeClient.stop(); + + if (privateChannelId != null) { + String channelId = privateChannelId; + slack.methods(botToken).conversationsArchive(r -> r.channel(channelId)); + } + } + } + + // ---------------------------- + // DM + // ---------------------------- + + // im_open + // im_created + // im_close + + @Data + public static class ImTestState { + private boolean imOpen; + private boolean imCreated; + private boolean imClose; + + public boolean isAllDone() { + return imOpen + // && imCreated + && imClose; + } + } + + // ---------------------------- + // Usergroups + // ---------------------------- + + // subteam_created + // subteam_members_changed + // subteam_self_added + // subteam_self_removed + // subteam_updated + + @Data + public static class SubteamTestState { + private boolean subteamCreated; + private boolean subteamMembersChanged; + private boolean subteamSelfAdded; + private boolean subteamSelfRemoved; + private boolean subteamUpdated; + + public boolean isAllDone() { + return subteamCreated + && subteamMembersChanged + && subteamSelfAdded + && subteamSelfRemoved + // Started failing on May 12, 2021 + // && subteamUpdated + ; + } + } + + @Test + public void usergroups() throws Exception { + + App app = new App(appConfig); + + String botToken = appConfig.getSingleTeamBotToken(); + String userToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_WORKSPACE_USER_TOKEN); + + SocketModeApp socketModeClient = new SocketModeApp(appToken, app); + SubteamTestState state = new SubteamTestState(); + + String createdUsergroupId = null; + try { + + // subteam_created + app.event(SubteamCreatedEvent.class, (req, ctx) -> { + state.setSubteamCreated(true); + return ctx.ack(); + }); + // subteam_members_changed + app.event(SubteamMembersChangedEvent.class, (req, ctx) -> { + state.setSubteamMembersChanged(true); + return ctx.ack(); + }); + // subteam_self_added + app.event(SubteamSelfAddedEvent.class, (req, ctx) -> { + state.setSubteamSelfAdded(true); + return ctx.ack(); + }); + // subteam_self_removed + app.event(SubteamSelfRemovedEvent.class, (req, ctx) -> { + state.setSubteamSelfRemoved(true); + return ctx.ack(); + }); + // subteam_updated + app.event(SubteamUpdatedEvent.class, (req, ctx) -> { + state.setSubteamUpdated(true); + return ctx.ack(); + }); + + // ------------------------------------------------------------------------------------ + + socketModeClient.startAsync(); + + waitForSlackAppConnection(); + + // ------------------------------------------------------------------------------------ + + String userId = slack.methods().authTest(r -> r.token(userToken)).getUserId(); + String botUserId = slack.methods().authTest(r -> r.token(botToken)).getUserId(); + + // subteam_created + UsergroupsCreateResponse creation = slack.methods(userToken).usergroupsCreate(r -> + r.name("test-group-" + System.currentTimeMillis()).description("test")); + assertNull(creation.getError()); + String usergroupId = creation.getUsergroup().getId(); + createdUsergroupId = usergroupId; + // subteam_members_changed + UsergroupsUsersUpdateResponse memberUpdates = + slack.methods(userToken).usergroupsUsersUpdate(r -> + r.usergroup(usergroupId).users(Arrays.asList(userId))); + assertNull(memberUpdates.getError()); + // subteam_self_added + UsergroupsUsersUpdateResponse self = + slack.methods(userToken).usergroupsUsersUpdate(r -> + r.usergroup(usergroupId).users(Arrays.asList(userId, botUserId))); + assertNull(self.getError()); + // subteam_self_removed + UsergroupsUsersUpdateResponse selfRemoval = + slack.methods(userToken).usergroupsUsersUpdate(r -> + r.usergroup(usergroupId).users(Arrays.asList(botUserId))); + assertNull(selfRemoval.getError()); + // subteam_updated + + // ------------------------------------------------------------------------------------ + + long waitTime = 0; + while (!state.isAllDone() && waitTime < 10_000L) { + long sleepTime = 100L; + Thread.sleep(sleepTime); + waitTime += sleepTime; + } + assertTrue(state.toString(), state.isAllDone()); + + } finally { + socketModeClient.stop(); + + if (createdUsergroupId != null) { + String id = createdUsergroupId; + slack.methods(userToken).usergroupsDisable(r -> r.usergroup(id)); + } + } + } + + // ---------------------------- + // User Settings + // ---------------------------- + // dnd_updated + // dnd_updated_user + + @Data + public static class DndTestState { + private boolean dndUpdated; + private boolean dndUpdatedUser; + + public boolean isAllDone() { + // NOTE: As of Sep 3 2020, dnd_updated_user events are not sent to app + // return dndUpdated && dndUpdatedUser; + return dndUpdated; + } + } + + @Test + public void dnd() throws Exception { + + App app = new App(appConfig); + + String userToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_WORKSPACE_USER_TOKEN); + + SocketModeApp socketModeClient = new SocketModeApp(appToken, app); + DndTestState state = new DndTestState(); + + try { + // Subscribe to events on behalf of users + + // dnd_updated + app.event(DndUpdatedEvent.class, (req, ctx) -> { + state.setDndUpdated(true); + return ctx.ack(); + }); + // dnd_updated_user + app.event(DndUpdatedUserEvent.class, (req, ctx) -> { + state.setDndUpdatedUser(true); + return ctx.ack(); + }); + + // ------------------------------------------------------------------------------------ + + socketModeClient.startAsync(); + + waitForSlackAppConnection(); + + // ------------------------------------------------------------------------------------ + + // dnd_updated + // dnd_updated_user + DndSetSnoozeResponse userSetSnooze = slack.methods(userToken).dndSetSnooze(r -> r.numMinutes(10)); + assertNull(userSetSnooze.getError()); + DndEndSnoozeResponse userEndSnooze = slack.methods(userToken).dndEndSnooze(r -> r); + assertNull(userEndSnooze.getError()); + + // ------------------------------------------------------------------------------------ + + long waitTime = 0; + while (!state.isAllDone() && waitTime < 10_000L) { + long sleepTime = 100L; + Thread.sleep(sleepTime); + waitTime += sleepTime; + } + assertTrue(state.toString(), state.isAllDone()); + + } finally { + socketModeClient.stop(); + } + } + + // ---------------------------- + // Message with files + // ---------------------------- + + @Data + public static class FileMessageState { + private AtomicInteger fileShare = new AtomicInteger(0); + private AtomicInteger messageChanged = new AtomicInteger(0); + + public boolean isAllDone() { + return fileShare.get() == 1 && messageChanged.get() == 1; + } + } + + @Test + public void messageWithFiles() throws Exception { + + App app = new App(appConfig); + + String userToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_WORKSPACE_USER_TOKEN); + String botToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_WORKSPACE_BOT_TOKEN); + + SocketModeApp socketModeClient = new SocketModeApp(appToken, app); + FileMessageState state = new FileMessageState(); + + ConversationsCreateResponse publicChannel = + slack.methods(botToken).conversationsCreate(r -> r.name("test-" + System.currentTimeMillis()).isPrivate(false)); + assertNull(publicChannel.getError()); + + final String channelId = publicChannel.getChannel().getId(); + + ConversationsJoinResponse joining = slack.methods(userToken).conversationsJoin(r -> r.channel(channelId)); + assertNull(joining.getError()); + + try { + app.event(MessageFileShareEvent.class, (req, ctx) -> { + if (req.getEvent().getFiles() != null && req.getEvent().getFiles().size() > 0) { + state.fileShare.incrementAndGet(); + } + return ctx.ack(); + }); + // FIXME: this is not called as of July 30, 2021 + app.event(MessageChangedEvent.class, (req, ctx) -> { + if (req.getEvent().getMessage().getFiles() != null && req.getEvent().getMessage().getFiles().size() > 0 + && req.getEvent().getPreviousMessage().getMessage().getFiles() != null && req.getEvent().getPreviousMessage().getMessage().getFiles().size() > 0) { + state.messageChanged.incrementAndGet(); + } + return ctx.ack(); + }); + + // ------------------------------------------------------------------------------------ + socketModeClient.startAsync(); + waitForSlackAppConnection(); + // ------------------------------------------------------------------------------------ + + FilesUploadRequest uploadRequest = FilesUploadRequest.builder() + .title("test text") + .content("test test test") + .channels(Arrays.asList(channelId)) + .initialComment("Here you are") + .build(); + FilesUploadResponse userResult = slack.methods(userToken).filesUpload(uploadRequest); + assertNull(userResult.getError()); + + String ts = userResult.getFile().getShares().getPublicChannels().get(channelId).get(0).getTs(); + ChatUpdateResponse updateResult = slack.methods(userToken).chatUpdate(r -> r + .channel(channelId) + .ts(ts) + .text("Here you are - let me know if you need other files as well")); + assertNull(updateResult.getError()); + // ------------------------------------------------------------------------------------ + + long waitTime = 0; + while (!state.isAllDone() && waitTime < 5_000L) { + long sleepTime = 100L; + Thread.sleep(sleepTime); + waitTime += sleepTime; + } + // FIXME: failing as of July 30, 2021 + assertTrue(state.toString(), state.isAllDone()); + + } finally { + socketModeClient.stop(); + + if (channelId != null) { + slack.methods(botToken).conversationsArchive(r -> r.channel(channelId)); + } + } + } + + + // ---------------------------- + // apps.event.authorizations.list + // ---------------------------- + + @Test + public void appsEventAuthorizationsListCalls() throws Exception { + + App app = new App(appConfig); + + String appLevelToken = System.getenv(Constants.SLACK_SDK_TEST_APP_TOKEN); + String userToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_WORKSPACE_USER_TOKEN); + String botToken = System.getenv(Constants.SLACK_SDK_TEST_GRID_WORKSPACE_BOT_TOKEN); + + SocketModeApp socketModeClient = new SocketModeApp(appToken, app); + + ConversationsCreateResponse publicChannel = + slack.methods(botToken).conversationsCreate(r -> r + .name("test-" + System.currentTimeMillis()).isPrivate(false)); + assertNull(publicChannel.getError()); + + final String channelId = publicChannel.getChannel().getId(); + ConversationsJoinResponse joining = slack.methods(userToken).conversationsJoin(r -> r.channel(channelId)); + assertNull(joining.getError()); + + final AtomicBoolean called = new AtomicBoolean(false); + + try { + app.event(MessageEvent.class, (req, ctx) -> { + AppsEventAuthorizationsListResponse authorizations = recorderSlack.methods(appLevelToken).appsEventAuthorizationsList(r -> r + .eventContext(req.getEventContext()) + .limit(10) + ); + called.set(authorizations.isOk()); + return ctx.ack(); + }); + + // ------------------------------------------------------------------------------------ + socketModeClient.startAsync(); + waitForSlackAppConnection(); + // ------------------------------------------------------------------------------------ + + ChatPostMessageResponse chatPostMessageResponse = + slack.methods(userToken).chatPostMessage(r -> r.channel(channelId).text("Hi there!")); + assertNull(chatPostMessageResponse.getError()); + + // ------------------------------------------------------------------------------------ + + long waitTime = 0; + while (!called.get() && waitTime < 5_000L) { + long sleepTime = 100L; + Thread.sleep(sleepTime); + waitTime += sleepTime; + } + assertTrue(called.get()); + + } finally { + socketModeClient.stop(); + if (channelId != null) { + slack.methods(botToken).conversationsArchive(r -> r.channel(channelId)); + } + } + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/util/PortProvider.java b/bolt-jakarta-socket-mode/src/test/java/util/PortProvider.java new file mode 100644 index 000000000..f8ded4c9f --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/util/PortProvider.java @@ -0,0 +1,41 @@ +package util; + +import java.io.IOException; +import java.net.Socket; +import java.security.SecureRandom; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class PortProvider { + + private PortProvider() { + } + + private static final int MINIMUM = 1024; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final ConcurrentMap PORTS = new ConcurrentHashMap<>(); + + public static int getPort(String name) { + return PORTS.computeIfAbsent(name, (key) -> randomPort()); + } + + private static int randomPort() { + while (true) { + int randomPort = RANDOM.nextInt(9999); + if (randomPort < MINIMUM) { + randomPort += MINIMUM; + } + if (isAvailable(randomPort)) { + return randomPort; + } + } + } + + private static boolean isAvailable(int port) { + try (Socket ignored = new Socket("127.0.0.1", port)) { + return false; + } catch (IOException ignored) { + return true; + } + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/util/ResourceLoader.java b/bolt-jakarta-socket-mode/src/test/java/util/ResourceLoader.java new file mode 100644 index 000000000..d5a7d6910 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/util/ResourceLoader.java @@ -0,0 +1,106 @@ +package util; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.slack.api.bolt.AppConfig; +import lombok.extern.slf4j.Slf4j; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.Map; + +import static java.util.stream.Collectors.joining; + +@Slf4j +public class ResourceLoader { + + private ResourceLoader() { + } + + public static AppConfig loadAppConfig(String fileName) { + AppConfig config = new AppConfig(); + ClassLoader classLoader = ResourceLoader.class.getClassLoader(); + // https://github.com/slackapi/java-slack-sdk/blob/main/bolt-servlet/src/test/resources + try (InputStream is = classLoader.getResourceAsStream(fileName)) { + if (is != null) { + try (InputStreamReader isr = new InputStreamReader(is)) { + String json = new BufferedReader(isr).lines().collect(joining()); + JsonObject j = new Gson().fromJson(json, JsonElement.class).getAsJsonObject(); + config.setSigningSecret(j.get("signingSecret").getAsString()); + if (j.get("singleTeamBotToken") != null) { + config.setSingleTeamBotToken(j.get("singleTeamBotToken").getAsString()); + } + if (j.get("clientId") != null) { + config.setClientId(j.get("clientId").getAsString()); + } + if (j.get("clientSecret") != null) { + config.setClientSecret(j.get("clientSecret").getAsString()); + } + if (j.get("scope") != null) { + config.setScope(j.get("scope").getAsString()); + } + if (j.get("userScope") != null) { + config.setUserScope(j.get("userScope").getAsString()); + } + if (j.get("oauthCompletionUrl") != null) { + config.setOauthCompletionUrl(j.get("oauthCompletionUrl").getAsString()); + } + if (j.get("oauthInstallPath") != null) { + config.setOauthInstallPath(j.get("oauthInstallPath").getAsString()); + } + if (j.get("oauthRedirectUriPath") != null) { + config.setOauthRedirectUriPath(j.get("oauthRedirectUriPath").getAsString()); + } + if (j.get("redirectUri") != null) { + config.setRedirectUri(j.get("redirectUri").getAsString()); + } + } catch (IOException e) { + log.error(e.getMessage(), e); + } + } + } catch (IOException e) { + log.error(e.getMessage(), e); + } + return config; + } + + public static AppConfig loadAppConfig() { + return loadAppConfig("appConfig.json"); + } + + public static Map loadValues() { + ClassLoader classLoader = ResourceLoader.class.getClassLoader(); + // src/test/resources + try (InputStream is = classLoader.getResourceAsStream("appConfig.json")) { + if (is == null) { + throw new RuntimeException("Place src/test/resources/appConfig.json!"); + } + try (InputStreamReader isr = new InputStreamReader(is)) { + String json = new BufferedReader(isr).lines().collect(joining()); + return new Gson().fromJson(json, HashMap.class); + } catch (IOException e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + } catch (IOException e) { + log.error(e.getMessage(), e); + throw new RuntimeException(e); + } + } + + public static String load(String filepath) { + ClassLoader classLoader = ResourceLoader.class.getClassLoader(); + try (InputStream is = classLoader.getResourceAsStream(filepath); + InputStreamReader isr = new InputStreamReader(is)) { + return new BufferedReader(isr).lines().collect(joining()); + } catch (IOException e) { + log.error(e.getMessage(), e); + } + return null; + } + +} diff --git a/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockSocketMode.java b/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockSocketMode.java new file mode 100644 index 000000000..78f049477 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockSocketMode.java @@ -0,0 +1,255 @@ +package util.socket_mode; + +import com.slack.api.util.thread.DaemonThreadExecutorServiceProvider; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.eclipse.jetty.websocket.api.WebSocketAdapter; + +import java.io.IOException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class MockSocketMode extends WebSocketAdapter { + private CountDownLatch closureLatch = new CountDownLatch(1); + + private CopyOnWriteArrayList activeSessions = new CopyOnWriteArrayList<>(); + private ScheduledExecutorService service = DaemonThreadExecutorServiceProvider.getInstance().createThreadScheduledExecutor( + MockSocketMode.class.getCanonicalName()); + + public MockSocketMode() { + super(); + service.scheduleAtFixedRate(() -> { + List stoleSessions = new ArrayList<>(); + for (Session session : activeSessions) { + if (session.isOpen()) { + try { + session.getRemote().sendString(getRandomEnvelope()); + } catch (IOException e) { + log.error("Failed to send a message", e); + } + } else { + stoleSessions.add(session); + } + } + activeSessions.removeAll(stoleSessions); + + }, 200L, 100L, TimeUnit.MILLISECONDS); + } + + @Override + public void onWebSocketConnect(Session session) { + super.onWebSocketConnect(session); + this.activeSessions.add(session); + log.info("connected: {}", session); + try { + this.getRemote().sendString("{\n" + + " \"type\": \"hello\",\n" + + " \"num_connections\": 1,\n" + + " \"debug_info\": {\n" + + " \"host\": \"applink-xxx-yyy\",\n" + + " \"build_number\": 999,\n" + + " \"approximate_connection_time\": 18060\n" + + " },\n" + + " \"connection_info\": {\n" + + " \"app_id\": \"A111\"\n" + + " }\n" + + "}"); + } catch (IOException e) { + log.error("Failed to send hello message", e); + } + } + + @Override + public void onWebSocketText(String message) { + super.onWebSocketText(message); + log.info("text: {}", message); + if (message.toLowerCase(Locale.US).contains("bye")) { + getSession().close(StatusCode.NORMAL, "Thanks"); + } + } + + @Override + public void onWebSocketClose(int statusCode, String reason) { + super.onWebSocketClose(statusCode, reason); + log.info("closed: (code: {}, reason: {})", statusCode, reason); + closureLatch.countDown(); + } + + @Override + public void onWebSocketError(Throwable cause) { + super.onWebSocketError(cause); + cause.printStackTrace(System.err); + } + + String interactiveEnvelope = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"type\": \"block_actions\",\n" + + " \"user\": {\n" + + " \"id\": \"U111\",\n" + + " \"username\": \"test-test-test\",\n" + + " \"name\": \"test-test-test\",\n" + + " \"team_id\": \"T111\"\n" + + " },\n" + + " \"api_app_id\": \"A111\",\n" + + " \"token\": \"fixed-value\",\n" + + " \"container\": {\n" + + " \"type\": \"message\",\n" + + " \"message_ts\": \"1605853634.000400\",\n" + + " \"channel_id\": \"C111\",\n" + + " \"is_ephemeral\": false\n" + + " },\n" + + " \"trigger_id\": \"111.222.xxx\",\n" + + " \"team\": {\n" + + " \"id\": \"T111\",\n" + + " \"domain\": \"test-test-test\"\n" + + " },\n" + + " \"channel\": {\n" + + " \"id\": \"C111\",\n" + + " \"name\": \"random\"\n" + + " },\n" + + " \"message\": {\n" + + " \"bot_id\": \"B111\",\n" + + " \"type\": \"message\",\n" + + " \"text\": \"This content can't be displayed.\",\n" + + " \"user\": \"U222\",\n" + + " \"ts\": \"1605853634.000400\",\n" + + " \"team\": \"T111\",\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"actions\",\n" + + " \"block_id\": \"b\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"button\",\n" + + " \"action_id\": \"a\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Click Me!\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"value\": \"underlying\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"response_url\": \"https://hooks.slack.com/actions/T111/111/xxx\",\n" + + " \"actions\": [\n" + + " {\n" + + " \"action_id\": \"a\",\n" + + " \"block_id\": \"b\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Click Me!\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"value\": \"underlying\",\n" + + " \"type\": \"button\",\n" + + " \"action_ts\": \"1605853645.582706\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"type\": \"interactive\",\n" + + " \"accepts_response_payload\": false\n" + + "}\n"; + + String eventsEnvelope = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"token\": \"fixed-value\",\n" + + " \"team_id\": \"T111\",\n" + + " \"api_app_id\": \"A111\",\n" + + " \"event\": {\n" + + " \"client_msg_id\": \"1748313e-912c-4942-a562-99754707692c\",\n" + + " \"type\": \"app_mention\",\n" + + " \"text\": \"<@U222> hey\",\n" + + " \"user\": \"U111\",\n" + + " \"ts\": \"1605853844.000800\",\n" + + " \"team\": \"T111\",\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"rich_text\",\n" + + " \"block_id\": \"K8xp\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"rich_text_section\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"user\",\n" + + " \"user_id\": \"U222\"\n" + + " },\n" + + " {\n" + + " \"type\": \"text\",\n" + + " \"text\": \" hey\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"channel\": \"C111\",\n" + + " \"event_ts\": \"1605853844.000800\"\n" + + " },\n" + + " \"type\": \"event_callback\",\n" + + " \"event_id\": \"Ev01ERKCFKK9\",\n" + + " \"event_time\": 1605853844,\n" + + " \"authorizations\": [\n" + + " {\n" + + " \"enterprise_id\": null,\n" + + " \"team_id\": \"T111\",\n" + + " \"user_id\": \"U222\",\n" + + " \"is_bot\": true,\n" + + " \"is_enterprise_install\": false\n" + + " }\n" + + " ],\n" + + " \"is_ext_shared_channel\": false,\n" + + " \"event_context\": \"1-app_mention-T111-C111\"\n" + + " },\n" + + " \"type\": \"events_api\",\n" + + " \"accepts_response_payload\": false,\n" + + " \"retry_attempt\": 2,\n" + + " \"retry_reason\": \"timeout\"\n" + + "}\n"; + + String commandEnvelope = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"token\": \"fixed-value\",\n" + + " \"team_id\": \"T111\",\n" + + " \"team_domain\": \"test-test-test\",\n" + + " \"channel_id\": \"C111\",\n" + + " \"channel_name\": \"random\",\n" + + " \"user_id\": \"U111\",\n" + + " \"user_name\": \"test-test-test\",\n" + + " \"command\": \"/hi-socket-mode\",\n" + + " \"text\": \"\",\n" + + " \"api_app_id\": \"A111\",\n" + + " \"response_url\": \"https://hooks.slack.com/commands/T111/111/xxx\",\n" + + " \"trigger_id\": \"111.222.xxx\"\n" + + " },\n" + + " \"type\": \"slash_commands\",\n" + + " \"accepts_response_payload\": true\n" + + "}\n"; + + List envelopes = Arrays.asList( + interactiveEnvelope, + eventsEnvelope, + commandEnvelope + ); + + private SecureRandom random = new SecureRandom(); + + private String getRandomEnvelope() { + return envelopes.get(random.nextInt(envelopes.size())); + } +} \ No newline at end of file diff --git a/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebApi.java b/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebApi.java new file mode 100644 index 000000000..d50edcb79 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebApi.java @@ -0,0 +1,73 @@ +package util.socket_mode; + +import lombok.extern.slf4j.Slf4j; + +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.stream.Collectors; + +import static util.socket_mode.MockWebSocketServer.WEB_SOCKET_SERVER_PORT; + +@WebServlet +@Slf4j +public class MockWebApi extends HttpServlet { + + public static final String VALID_APP_TOKEN_PREFIX = "xapp-valid"; + public static final String VALID_BOT_TOKEN_PREFIX = "xoxb-valid"; + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + try (InputStream is = req.getInputStream(); + InputStreamReader isr = new InputStreamReader(is); + BufferedReader br = new BufferedReader(isr)) { + String requestBody = br.lines().collect(Collectors.joining()); + log.info("request body: {}", requestBody); + } + String authorizationHeader = req.getHeader("Authorization"); + if (authorizationHeader != null + && !authorizationHeader.trim().isEmpty()) { + String methodName = req.getRequestURI().replaceFirst("^/api/", ""); + if (methodName.equals("apps.connections.open")) { + if (authorizationHeader.startsWith("Bearer " + VALID_APP_TOKEN_PREFIX)) { + resp.setStatus(200); + String port = System.getProperty(WEB_SOCKET_SERVER_PORT); + resp.getWriter().write("{\"ok\":true,\"url\":\"ws:\\/\\/127.0.0.1:" + port + "\\/\"}"); + resp.setContentType("application/json"); + return; + } + } + if (methodName.equals("auth.test")) { + if (authorizationHeader.startsWith("Bearer " + VALID_BOT_TOKEN_PREFIX)) { + String body = "{\n" + + " \"ok\": true,\n" + + " \"url\": \"https://java-slack-sdk-test.slack.com/\",\n" + + " \"team\": \"java-slack-sdk-test\",\n" + + " \"user\": \"test_user\",\n" + + " \"team_id\": \"T1234567\",\n" + + " \"user_id\": \"U1234567\",\n" + + " \"bot_id\": \"B12345678\",\n" + + " \"enterprise_id\": \"E12345678\",\n" + + " \"error\": \"\"\n" + + "}"; + resp.setStatus(200); + resp.getWriter().write(body); + resp.setContentType("application/json"); + return; + } + } + } else if (!authorizationHeader.startsWith("Bearer " + VALID_APP_TOKEN_PREFIX)) { + resp.setStatus(200); + resp.getWriter().write("{\"ok\":false,\"error\":\"invalid_auth\"}"); + resp.setContentType("application/json"); + return; + } + resp.setStatus(404); + resp.getWriter().write("Not Found"); + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebApiServer.java b/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebApiServer.java new file mode 100644 index 000000000..0adf99251 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebApiServer.java @@ -0,0 +1,52 @@ +package util.socket_mode; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletHandler; +import util.PortProvider; + +import java.net.SocketException; + +public class MockWebApiServer { + + private int port; + private Server server; + + public MockWebApiServer() { + this(PortProvider.getPort(MockWebApiServer.class.getName())); + } + + public MockWebApiServer(int port) { + setup(port); + } + + private void setup(int port) { + this.port = port; + this.server = new Server(this.port); + ServletHandler handler = new ServletHandler(); + server.setHandler(handler); + handler.addServletWithMapping(MockWebApi.class, "/*"); + } + + public String getMethodsEndpointPrefix() { + return "http://127.0.0.1:" + port + "/api/"; + } + + public void start() throws Exception { + int retryCount = 0; + while (retryCount < 5) { + try { + server.start(); + return; + } catch (SocketException e) { + // java.net.SocketException: Permission denied may arise + // only on the GitHub Actions environment. + setup(PortProvider.getPort(MockWebApiServer.class.getName())); + retryCount++; + } + } + } + + public void stop() throws Exception { + server.stop(); + } +} diff --git a/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebSocketServer.java b/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebSocketServer.java new file mode 100644 index 000000000..285626401 --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/java/util/socket_mode/MockWebSocketServer.java @@ -0,0 +1,65 @@ +package util.socket_mode; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.websocket.server.NativeWebSocketServletContainerInitializer; +import org.eclipse.jetty.websocket.server.WebSocketUpgradeFilter; +import util.PortProvider; + +import javax.servlet.ServletException; +import java.net.SocketException; + +public class MockWebSocketServer { + + public static final String WEB_SOCKET_SERVER_PORT = "WEB_SOCKET_SERVER_PORT"; + + private Server server; + + public MockWebSocketServer() { + setup(); + } + + public void setup() { + server = new Server(); + ServerConnector connector = new ServerConnector(server); + int port = PortProvider.getPort(MockWebSocketServer.class.getName()); + System.setProperty(WEB_SOCKET_SERVER_PORT, String.valueOf(port)); + connector.setPort(port); + server.addConnector(connector); + + ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); + context.setContextPath("/"); + server.setHandler(context); + + NativeWebSocketServletContainerInitializer initializer = new NativeWebSocketServletContainerInitializer(); + initializer.getDefaultFrom(context.getServletContext()).addMapping("/*", MockSocketMode.class); + + try { + WebSocketUpgradeFilter upgradeFilter = WebSocketUpgradeFilter.configureContext(context); + context.setAttribute(WebSocketUpgradeFilter.class.getName() + ".SCI", upgradeFilter); + } catch (ServletException e) { + throw new RuntimeException(e); + } + } + + public void start() throws Exception { + int retryCount = 0; + while (retryCount < 5) { + try { + server.start(); + return; + } catch (SocketException e) { + // java.net.SocketException: Permission denied may arise + // only on the GitHub Actions environment. + setup(); + retryCount++; + } + } + } + + public void stop() throws Exception { + server.stop(); + } + +} diff --git a/bolt-jakarta-socket-mode/src/test/resources/logback.xml b/bolt-jakarta-socket-mode/src/test/resources/logback.xml new file mode 100644 index 000000000..c9bdbc77d --- /dev/null +++ b/bolt-jakarta-socket-mode/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + logs/console.log + + %date %level [%thread] %logger{64} %msg%n + + + + + + + + + \ No newline at end of file diff --git a/docs/content/reference.md b/docs/content/reference.md index 35fe5c124..34ff5a796 100644 --- a/docs/content/reference.md +++ b/docs/content/reference.md @@ -6,27 +6,29 @@ All released versions are available on the Maven Central repositories. The lates ## Bolt & Built-in Extensions -|groupId:artifactId| Javadoc | Description | -|------------------|---------|-------------| -|[`com.slack.api:bolt`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt/sdkLatestVersion/bolt-sdkLatestVersion-javadoc.jar/!/index.html#package)| Bolt is a framework that offers an abstraction layer to build Slack apps safely and quickly. | -|[`com.slack.api:bolt-socket-mode`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-socket-mode) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-socket-mode/sdkLatestVersion/bolt-socket-mode-sdkLatestVersion-javadoc.jar/!/index.html#package)| This module offers a handy way to run Bolt apps through [Socket Mode](https://api.slack.com/) connections. | -|[`com.slack.api:bolt-servlet`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-servlet) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-servlet/sdkLatestVersion/bolt-servlet-sdkLatestVersion-javadoc.jar/!/index.html)| This module offers a handy way to run Bolt apps on the Java EE Servlet environments. | -|[`com.slack.api:bolt-jetty`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-jetty) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-jetty/sdkLatestVersion/bolt-jetty-sdkLatestVersion-javadoc.jar/!/index.html)| This module offers a handy way to run Bolt apps on the [Java EE compatible Jetty HTTP server (9.x)](https://www.eclipse.org/jetty/). | -|[`com.slack.api:bolt-jakarta-servlet`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-jakarta-servlet) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-jakarta-servlet/sdkLatestVersion/bolt-jakarta-servlet-sdkLatestVersion-javadoc.jar/!/index.html)| This module offers a handy way to run Bolt apps on the Jakarta EE Servlet environments. | -|[`com.slack.api:bolt-jakarta-jetty`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-jakarta-jetty)| [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-jakarta-jetty/sdkLatestVersion/bolt-jakarta-jetty-sdkLatestVersion-javadoc.jar/!/index.html)| This module offers a handy way to run Bolt apps on the [Jakarta EE compatible Jetty HTTP server](https://www.eclipse.org/jetty/). | -|[`com.slack.api:bolt-aws-lambda`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-aws-lambda) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-aws-lambda/sdkLatestVersion/bolt-aws-lambda-sdkLatestVersion-javadoc.jar/!/index.html)| This module offers a handy way to run Bolt apps on AWS [API Gateway](https://aws.amazon.com/api-gateway/) + [Lambda](https://aws.amazon.com/lambda/). | -|[`com.slack.api:bolt-google-cloud-functions`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-google-cloud-functions)| [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-google-cloud-functions/sdkLatestVersion/bolt-google-cloud-functions-sdkLatestVersion-javadoc.jar/!/index.html)| This module offers a handy way to run Bolt apps on [Google Cloud Functions](https://cloud.google.com/functions). | -|[`com.slack.api:bolt-micronaut`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-micronaut)| [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-micronaut/sdkLatestVersion/bolt-micronaut-sdkLatestVersion-javadoc.jar/!/index.html)| This is an adapter for [Micronaut](https://micronaut.io/) to run Bolt apps on top of it. | -|[`com.slack.api:bolt-helidon`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-helidon) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-helidon/sdkLatestVersion/bolt-helidon-sdkLatestVersion-javadoc.jar/!/index.html)| This is an adapter for [Helidon SE](https://helidon.io/docs/latest/) to run Bolt apps on top of it. | -|[`com.slack.api:bolt-http4k`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-http4k) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-http4k/sdkLatestVersion/bolt-http4k-sdkLatestVersion-javadoc.jar/!/index.html)| This is an adapter for [http4k](https://http4k.org/) to run Bolt apps on top of any of the multiple server backends that the library supports. | -|[`com.slack.api:bolt-ktor`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-ktor) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-ktor/sdkLatestVersion/bolt-ktor-sdkLatestVersion-javadoc.jar/!/index.html)| This is an adapter for [Ktor](https://ktor.io/) to run Bolt apps on top of it. | +| groupId:artifactId | Javadoc | Description | +|----------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| +| [`com.slack.api:bolt`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt/sdkLatestVersion/bolt-sdkLatestVersion-javadoc.jar/!/index.html#package) | Bolt is a framework that offers an abstraction layer to build Slack apps safely and quickly. | +| [`com.slack.api:bolt-socket-mode`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-socket-mode) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-socket-mode/sdkLatestVersion/bolt-socket-mode-sdkLatestVersion-javadoc.jar/!/index.html#package) | This module offers a handy way to run Bolt apps through [Socket Mode](https://api.slack.com/) connections. | +| [`com.slack.api:bolt-jakarata-socket-mode`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-jakarta-socket-mode) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-jakarta-socket-mode/sdkLatestVersion/bolt-jakarta-socket-mode-sdkLatestVersion-javadoc.jar/!/index.html#package) | This module offers a handy way to run Bolt apps through [Socket Mode](https://api.slack.com/) connections. | +| [`com.slack.api:bolt-servlet`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-servlet) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-servlet/sdkLatestVersion/bolt-servlet-sdkLatestVersion-javadoc.jar/!/index.html) | This module offers a handy way to run Bolt apps on the Java EE Servlet environments. | +| [`com.slack.api:bolt-jetty`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-jetty) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-jetty/sdkLatestVersion/bolt-jetty-sdkLatestVersion-javadoc.jar/!/index.html) | This module offers a handy way to run Bolt apps on the [Java EE compatible Jetty HTTP server (9.x)](https://www.eclipse.org/jetty/). | +| [`com.slack.api:bolt-jakarta-servlet`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-jakarta-servlet) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-jakarta-servlet/sdkLatestVersion/bolt-jakarta-servlet-sdkLatestVersion-javadoc.jar/!/index.html) | This module offers a handy way to run Bolt apps on the Jakarta EE Servlet environments. | +| [`com.slack.api:bolt-jakarta-jetty`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-jakarta-jetty) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-jakarta-jetty/sdkLatestVersion/bolt-jakarta-jetty-sdkLatestVersion-javadoc.jar/!/index.html) | This module offers a handy way to run Bolt apps on the [Jakarta EE compatible Jetty HTTP server](https://www.eclipse.org/jetty/). | +| [`com.slack.api:bolt-aws-lambda`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-aws-lambda) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-aws-lambda/sdkLatestVersion/bolt-aws-lambda-sdkLatestVersion-javadoc.jar/!/index.html) | This module offers a handy way to run Bolt apps on AWS [API Gateway](https://aws.amazon.com/api-gateway/) + [Lambda](https://aws.amazon.com/lambda/). | +| [`com.slack.api:bolt-google-cloud-functions`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-google-cloud-functions) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-google-cloud-functions/sdkLatestVersion/bolt-google-cloud-functions-sdkLatestVersion-javadoc.jar/!/index.html) | This module offers a handy way to run Bolt apps on [Google Cloud Functions](https://cloud.google.com/functions). | +| [`com.slack.api:bolt-micronaut`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-micronaut) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-micronaut/sdkLatestVersion/bolt-micronaut-sdkLatestVersion-javadoc.jar/!/index.html) | This is an adapter for [Micronaut](https://micronaut.io/) to run Bolt apps on top of it. | +| [`com.slack.api:bolt-helidon`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-helidon) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-helidon/sdkLatestVersion/bolt-helidon-sdkLatestVersion-javadoc.jar/!/index.html) | This is an adapter for [Helidon SE](https://helidon.io/docs/latest/) to run Bolt apps on top of it. | +| [`com.slack.api:bolt-http4k`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-http4k) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-http4k/sdkLatestVersion/bolt-http4k-sdkLatestVersion-javadoc.jar/!/index.html) | This is an adapter for [http4k](https://http4k.org/) to run Bolt apps on top of any of the multiple server backends that the library supports. | +| [`com.slack.api:bolt-ktor`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:bolt-ktor) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/bolt-ktor/sdkLatestVersion/bolt-ktor-sdkLatestVersion-javadoc.jar/!/index.html) | This is an adapter for [Ktor](https://ktor.io/) to run Bolt apps on top of it. | ## Foundation Modules -|groupId:artifactId |Javadoc| Description| -|---|---|---| -|[`com.slack.api:slack-api-model`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-api-model) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-api-model/sdkLatestVersion/slack-api-model-sdkLatestVersion-javadoc.jar/!/index.html)|This is a collection of the classes representing the [Slack core objects](https://api.slack.com/types) such as conversations, messages, users, blocks, and surfaces. As this is an essential part of the SDK, all other modules depend on this.| -|[`com.slack.api:slack-api-model-kotlin-extension`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-api-model-kotlin-extension) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-api-model-kotlin-extension/sdkLatestVersion/slack-api-model-kotlin-extension-sdkLatestVersion-javadoc.jar/!/index.html)|This contains the Block Kit Kotlin DSL builder, which allows you to define block kit structures via a Kotlin-native DSL.| -|[`com.slack.api:slack-api-client`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-api-client) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-api-client/sdkLatestVersion/slack-api-client-sdkLatestVersion-javadoc.jar/!/index.html)|This is a collection of the Slack API clients. The supported are Basic API Methods, Socket Mode API, RTM API, SCIM API, Audit Logs API, and Status API.| -|[`com.slack.api:slack-api-client-kotlin-extension`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-api-client-kotlin-extension) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-api-client-kotlin-extension/sdkLatestVersion/slack-api-client-kotlin-extension-sdkLatestVersion-javadoc.jar/!/index.html)|This contains extension methods for various slack client message builders so you can seamlessly use the Block Kit Kotlin DSL directly on the Java message builders.| -|[`com.slack.api:slack-app-backend`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-app-backend) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-app-backend/sdkLatestVersion/slack-app-backend-sdkLatestVersion-javadoc.jar/!/index.html)|This module is a set of Slack app server-side handlers and data classes for Events API, Interactive Components, Slash Commands, Actions, and OAuth flow. These are used by Bolt framework as the foundation of it in primitive layers.| \ No newline at end of file +| groupId:artifactId |Javadoc| Description | +|----------------------------------------------------------------------------------------------------------------------------------------------------|---|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [`com.slack.api:slack-api-model`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-api-model) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-api-model/sdkLatestVersion/slack-api-model-sdkLatestVersion-javadoc.jar/!/index.html)| This is a collection of the classes representing the [Slack core objects](https://api.slack.com/types) such as conversations, messages, users, blocks, and surfaces. As this is an essential part of the SDK, all other modules depend on this. | +| [`com.slack.api:slack-api-model-kotlin-extension`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-api-model-kotlin-extension) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-api-model-kotlin-extension/sdkLatestVersion/slack-api-model-kotlin-extension-sdkLatestVersion-javadoc.jar/!/index.html)| This contains the Block Kit Kotlin DSL builder, which allows you to define block kit structures via a Kotlin-native DSL. | +| [`com.slack.api:slack-api-client`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-api-client) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-api-client/sdkLatestVersion/slack-api-client-sdkLatestVersion-javadoc.jar/!/index.html)| This is a collection of the Slack API clients. The supported are Basic API Methods, Socket Mode API, RTM API, SCIM API, Audit Logs API, and Status API. | +| [`com.slack.api:slack-api-client-kotlin-extension`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-api-client-kotlin-extension) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-api-client-kotlin-extension/sdkLatestVersion/slack-api-client-kotlin-extension-sdkLatestVersion-javadoc.jar/!/index.html)| This contains extension methods for various slack client message builders so you can seamlessly use the Block Kit Kotlin DSL directly on the Java message builders. | +| [`com.slack.api:slack-jakarta-socket-mode-client`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-jakarta-socket-mode-client) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-jakarta-socket-mode-client/sdkLatestVersion/slack-jakarta-socket-mode-client-sdkLatestVersion-javadoc.jar/!/index.html)| This is an option to switch to Jakarta EE compatible Socket Mode client.| +| [`com.slack.api:slack-app-backend`](https://search.maven.org/search?q=g:com.slack.api%20AND%20a:slack-app-backend) | [Javadoc](https://oss.sonatype.org/service/local/repositories/releases/archive/com/slack/api/slack-app-backend/sdkLatestVersion/slack-app-backend-sdkLatestVersion-javadoc.jar/!/index.html)| This module is a set of Slack app server-side handlers and data classes for Events API, Interactive Components, Slash Commands, Actions, and OAuth flow. These are used by Bolt framework as the foundation of it in primitive layers. | \ No newline at end of file diff --git a/pom.xml b/pom.xml index e795d83a5..b2e67c76a 100644 --- a/pom.xml +++ b/pom.xml @@ -17,8 +17,10 @@ slack-api-model slack-api-model-kotlin-extension slack-app-backend + slack-jakarta-socket-mode-client bolt bolt-socket-mode + bolt-jakarta-socket-mode bolt-servlet bolt-jakarta-servlet bolt-jetty diff --git a/scripts/run_all_bolt_tests.sh b/scripts/run_all_bolt_tests.sh index e4c375358..592da076c 100755 --- a/scripts/run_all_bolt_tests.sh +++ b/scripts/run_all_bolt_tests.sh @@ -8,4 +8,14 @@ then fi ./mvnw install -Dmaven.test.skip=true && \ -./mvnw test -pl bolt -pl slack-app-backend -pl bolt-servlet -pl bolt-aws-lambda -pl bolt-micronaut -pl bolt-helidon -pl bolt-google-cloud-functions \ No newline at end of file +./mvnw test -pl bolt \ + -pl slack-app-backend \ + -pl bolt-servlet \ + -pl bolt-jakarta-servlet \ + -pl bolt-socket-mode \ + -pl bolt-jakarta-socket-mode \ + -pl bolt-aws-lambda \ + -pl bolt-micronaut \ + -pl bolt-helidon \ + -pl bolt-google-cloud-functions \ + -pl !bolt-quarkus-examples diff --git a/scripts/run_all_client_tests.sh b/scripts/run_all_client_tests.sh index 05923abe7..e8b3fd7ed 100755 --- a/scripts/run_all_client_tests.sh +++ b/scripts/run_all_client_tests.sh @@ -1,2 +1,6 @@ #!/bin/bash -./mvnw test -pl slack-api-model -pl slack-api-client -pl slack-api-model-kotlin-extension -pl slack-api-client-kotlin-extension +./mvnw test -pl slack-api-model \ + -pl slack-api-client \ + -pl slack-jakarta-socket-mode-client \ + -pl slack-api-model-kotlin-extension \ + -pl slack-api-client-kotlin-extension diff --git a/slack-api-client/src/main/java/com/slack/api/socket_mode/SocketModeClient.java b/slack-api-client/src/main/java/com/slack/api/socket_mode/SocketModeClient.java index ff7752ece..76602dc76 100644 --- a/slack-api-client/src/main/java/com/slack/api/socket_mode/SocketModeClient.java +++ b/slack-api-client/src/main/java/com/slack/api/socket_mode/SocketModeClient.java @@ -38,7 +38,7 @@ public interface SocketModeClient extends Closeable { */ enum Backend { /** - * org.glassfish.tyrus.bundles:tyrus-standalone-client + * org.glassfish.tyrus.bundles:tyrus-standalone-client 1.x */ Tyrus, /** diff --git a/slack-jakarta-socket-mode-client/pom.xml b/slack-jakarta-socket-mode-client/pom.xml new file mode 100644 index 000000000..db4f17e03 --- /dev/null +++ b/slack-jakarta-socket-mode-client/pom.xml @@ -0,0 +1,78 @@ + + 4.0.0 + + + com.slack.api + slack-sdk-parent + 1.41.1-SNAPSHOT + + + slack-jakarta-socket-mode-client + 1.41.1-SNAPSHOT + jar + + + 2.2.0 + 2.2.0 + + + + + com.slack.api + slack-api-model + ${project.version} + + + com.slack.api + slack-api-client + ${project.version} + + + + jakarta.websocket + jakarta.websocket-client-api + ${jakarta.websocket-api.version} + provided + + + org.glassfish.tyrus.bundles + tyrus-standalone-client + ${tyrus-standalone-client.version} + provided + + + + org.eclipse.jetty + jetty-servlet + ${jetty-for-tests.version} + test + + + org.eclipse.jetty + jetty-server + ${jetty-for-tests.version} + test + + + org.eclipse.jetty + jetty-webapp + ${jetty-for-tests.version} + test + + + org.eclipse.jetty.websocket + websocket-server + ${jetty-for-tests.version} + test + + + org.eclipse.jetty + jetty-proxy + ${jetty-for-tests.version} + test + + + + diff --git a/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/JakartaSocketModeClientFactory.java b/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/JakartaSocketModeClientFactory.java new file mode 100644 index 000000000..0f1ad72fe --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/JakartaSocketModeClientFactory.java @@ -0,0 +1,47 @@ +package com.slack.api.jakarta_socket_mode; + +import com.slack.api.Slack; +import com.slack.api.jakarta_socket_mode.impl.JakartaSocketModeClientTyrusImpl; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.response.apps.connections.AppsConnectionsOpenResponse; +import com.slack.api.socket_mode.SocketModeClient; + +import java.io.IOException; +import java.net.URISyntaxException; + +public class JakartaSocketModeClientFactory { + private JakartaSocketModeClientFactory() { + } + + public static SocketModeClient create(String appToken) throws IOException { + return create(Slack.getInstance(), appToken); + } + + public static SocketModeClient create(Slack slack, String appToken) throws IOException { + String url = issueSocketModeUrl(slack, appToken); + try { + return new JakartaSocketModeClientTyrusImpl(slack, appToken, url); + } catch (URISyntaxException e) { + String message = "Failed to connect to the Socket Mode API endpoint. (message: " + e.getMessage() + ")"; + throw new IOException(message, e); + } + } + + private static String issueSocketModeUrl(Slack slack, String appToken) throws IOException { + try { + AppsConnectionsOpenResponse response = slack.methods().appsConnectionsOpen(r -> r.token(appToken)); + if (response.isOk()) { + return response.getUrl(); + } else { + String message = "Failed to connect to the Socket Mode endpoint URL (error: " + response.getError() + ")"; + throw new IllegalStateException(message); + } + } catch (SlackApiException e) { + String message = "Failed to connect to the Socket Mode API endpoint. (" + + "status: " + e.getResponse().code() + ", " + + "error: " + (e.getError() != null ? e.getError().getError() : "") + + ")"; + throw new IOException(message, e); + } + } +} diff --git a/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/impl/JakartaSocketModeClientTyrusImpl.java b/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/impl/JakartaSocketModeClientTyrusImpl.java new file mode 100644 index 000000000..546c8af05 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/impl/JakartaSocketModeClientTyrusImpl.java @@ -0,0 +1,435 @@ +package com.slack.api.jakarta_socket_mode.impl; + +import com.google.gson.Gson; +import com.slack.api.Slack; +import com.slack.api.methods.SlackApiException; +import com.slack.api.socket_mode.SocketModeClient; +import com.slack.api.socket_mode.listener.EnvelopeListener; +import com.slack.api.socket_mode.listener.WebSocketCloseListener; +import com.slack.api.socket_mode.listener.WebSocketErrorListener; +import com.slack.api.socket_mode.listener.WebSocketMessageListener; +import com.slack.api.socket_mode.queue.SocketModeMessageQueue; +import com.slack.api.socket_mode.queue.impl.ConcurrentLinkedMessageQueue; +import com.slack.api.socket_mode.request.EventsApiEnvelope; +import com.slack.api.socket_mode.request.InteractiveEnvelope; +import com.slack.api.socket_mode.request.SlashCommandsEnvelope; +import com.slack.api.util.http.ProxyUrlUtil; +import com.slack.api.util.json.GsonFactory; +import jakarta.websocket.*; +import org.glassfish.tyrus.client.ClientManager; +import org.glassfish.tyrus.client.ClientProperties; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Socket Mode Client + */ +@ClientEndpoint +public class JakartaSocketModeClientTyrusImpl implements SocketModeClient { + + private Slack slack; + private String appToken; + private final Gson gson; + private URI wssUri; + private boolean autoReconnectEnabled; + private boolean autoReconnectOnCloseEnabled; + private SocketModeMessageQueue messageQueue; + private ScheduledExecutorService messageProcessorExecutor; + private boolean sessionMonitorEnabled; + private Optional sessionMonitorExecutor; + private final AtomicReference latestPong = new AtomicReference<>(); + + private final List webSocketMessageListeners = new CopyOnWriteArrayList<>(); + private final List> eventsApiEnvelopeListeners = new CopyOnWriteArrayList<>(); + + private final List> slashCommandsEnvelopeListeners = new CopyOnWriteArrayList<>(); + private final List> interactiveEnvelopeListeners = new CopyOnWriteArrayList<>(); + + private final List webSocketErrorListeners = new CopyOnWriteArrayList<>(); + private final List webSocketCloseListeners = new CopyOnWriteArrayList<>(); + + /** + * Current WebSocket session. This field is null when disconnected. + */ + private Session currentSession; + + /** + * Provides asynchronous clean up for old sessions. + */ + private final ExecutorService sessionCleanerExecutor; + + public JakartaSocketModeClientTyrusImpl(String appToken) throws URISyntaxException, IOException, SlackApiException { + this(Slack.getInstance(), appToken); + } + + public JakartaSocketModeClientTyrusImpl(Slack slack, String appToken) throws URISyntaxException, IOException, SlackApiException { + this(slack, appToken, slack.methods(appToken).appsConnectionsOpen(r -> r).getUrl()); + } + + public JakartaSocketModeClientTyrusImpl( + Slack slack, + String appToken, + String wssUrl) throws URISyntaxException { + this(slack, appToken, wssUrl, DEFAULT_MESSAGE_PROCESSOR_CONCURRENCY); + } + + public JakartaSocketModeClientTyrusImpl( + Slack slack, + String appToken, + String wssUrl, + int concurrency + ) throws URISyntaxException { + this( + slack, + appToken, + wssUrl, + concurrency, + new ConcurrentLinkedMessageQueue(), + true, + true, + DEFAULT_SESSION_MONITOR_INTERVAL_MILLISECONDS + ); + } + + public JakartaSocketModeClientTyrusImpl( + Slack slack, + String appToken, + String wssUrl, + int concurrency, + SocketModeMessageQueue messageQueue, + boolean autoReconnectEnabled, + boolean sessionMonitorEnabled, + long sessionMonitorIntervalMillis + ) throws URISyntaxException { + if (wssUrl == null) { + throw new IllegalArgumentException("The wss URL for using Socket Mode is absent."); + } + setSlack(slack); + setAppToken(appToken); + setWssUri(new URI(wssUrl)); + this.gson = GsonFactory.createSnakeCase(slack.getConfig()); + + setMessageQueue(messageQueue); + setAutoReconnectEnabled(autoReconnectEnabled); + // You can use the setter method if you set the value to true + setAutoReconnectOnCloseEnabled(false); + setSessionMonitorEnabled(sessionMonitorEnabled); + initializeSessionMonitorExecutor(sessionMonitorIntervalMillis); + initializeMessageProcessorExecutor(concurrency); + sessionCleanerExecutor = slack.getConfig() + .getExecutorServiceProvider() + .createThreadPoolExecutor(getExecutorGroupNamePrefix() + "-session-cleaner", 3); + } + + @Override + public long maintainCurrentSession() { + if (isAutoReconnectEnabled() && !verifyConnection()) { + getLogger().info("The current session is no longer active. Going to reconnect to the Socket Mode server."); + try { + connectToNewEndpoint(); + } catch (Exception e) { + getLogger().warn("Failed to connect to a new Socket Mode server endpoint: {}", e.getMessage(), e); + return System.currentTimeMillis() + 10_000L; + } + } + return System.currentTimeMillis(); + } + + @Override + public void connect() { + try { + ClientManager clientManager = ClientManager.createClient(); + Map proxyHeaders = getSlack().getHttpClient().getConfig().getProxyHeaders(); + String proxyUrl = getSlack().getHttpClient().getConfig().getProxyUrl(); + if (proxyUrl != null) { + if (getLogger().isDebugEnabled()) { + getLogger().debug("The SocketMode client's going to use an HTTP proxy: {}", proxyUrl); + } + ProxyUrlUtil.ProxyUrl parsedProxy = ProxyUrlUtil.parse(proxyUrl); + clientManager.getProperties().put(ClientProperties.PROXY_URI, parsedProxy.toUrlWithoutUserAndPassword()); + if (parsedProxy.getUsername() != null && parsedProxy.getPassword() != null) { + if (proxyHeaders == null) { + proxyHeaders = new HashMap<>(); + } + ProxyUrlUtil.setProxyAuthorizationHeader(proxyHeaders, parsedProxy); + } + } + if (proxyHeaders != null && !proxyHeaders.isEmpty()) { + clientManager.getProperties().put(ClientProperties.PROXY_HEADERS, proxyHeaders); + } + try { + setAutoReconnectEnabled(true); + Session newSession = clientManager.connectToServer(this, getWssUri()); + setCurrentSession(newSession); + } catch (DeploymentException e) { + throw new IOException(e); + } + if (getLogger().isDebugEnabled()) { + getLogger().debug("This Socket Mode client is successfully connected to the server: {}", getWssUri()); + } + } catch (IOException e) { + getLogger().error("Failed to reconnect to Socket Mode server: {}", e.getMessage(), e); + } + } + + @Override + public boolean verifyConnection() { + if (this.currentSession != null && this.currentSession.isOpen()) { + String ping = "ping-pong_" + currentSession.getId(); + if (getLogger().isDebugEnabled()) { + getLogger().debug("Sending a ping message: {}", ping); + } + ByteBuffer pingBytes = ByteBuffer.wrap(ping.getBytes()); + try { + RemoteEndpoint.Basic basicRemote = this.currentSession.getBasicRemote(); + latestPong.set(null); + basicRemote.sendPing(pingBytes); + long waitMillis = 0L; + while (waitMillis <= 3_000L) { + String pong = latestPong.getAndSet(null); + if (pong != null && pong.equals(ping)) { + if (getLogger().isDebugEnabled()) { + getLogger().debug("Received a pong message: {}", ping); + } + return true; + } + basicRemote.sendPing(pingBytes); + Thread.sleep(100L); + waitMillis += 100L; + } + } catch (Exception e) { + getLogger().warn("Failed to send a ping message (session id: {}, error: {})", + this.currentSession.getId(), + e.getMessage()); + } + if (getLogger().isDebugEnabled()) { + getLogger().debug("Failed to receive a pong message: {}", ping); + } + } + return false; + } + + @Override + public boolean isAutoReconnectOnCloseEnabled() { + return this.autoReconnectOnCloseEnabled; + } + + @Override + public void setAutoReconnectOnCloseEnabled(boolean autoReconnectOnCloseEnabled) { + this.autoReconnectOnCloseEnabled = autoReconnectOnCloseEnabled; + } + + @Override + public void disconnect() throws IOException { + setAutoReconnectEnabled(false); + if (currentSession != null) { + synchronized (currentSession) { + closeSession(currentSession); + } + } + } + + @OnOpen + public void onOpen(Session session) { + getLogger().info("New session is open (session id: {})", session.getId()); + if (verifyConnection()) { + setCurrentSession(session); + } + } + + @OnClose + public void onClose(Session session, CloseReason reason) { + getLogger().info("onClose listener is called (session id: {}, reason: {})", + session.getId(), reason.getReasonPhrase()); + runCloseListenersAndAutoReconnectAsNecessary( + reason.getCloseCode().getCode(), + reason.getReasonPhrase() + ); + } + + @OnError + public void onError(Session session, Throwable reason) { + getLogger().error("onError listener is called (session id: {}, reason: {})", session.getId(), reason); + runErrorListeners(reason); + } + + @OnMessage + public void onMessage(String message) { + enqueueMessage(message); + } + + @OnMessage + public void onPong(PongMessage message) { + latestPong.set(new String(message.getApplicationData().array())); + } + + /** + * Overwrites the underlying WebSocket session. + */ + private void setCurrentSession(Session newSession) { + if (this.currentSession == null) { + this.currentSession = newSession; + } else { + synchronized (this.currentSession) { + if (this.currentSession.getId().equals(newSession.getId())) { + return; + } + final Session oldSession = this.currentSession; + sessionCleanerExecutor.execute(() -> { + try { + closeSession(oldSession); + } catch (Exception e) { + getLogger().error("Failed to close an old session (session id: {}, exception: {})", + oldSession.getId(), e.getMessage(), e); + } + }); + this.currentSession = newSession; + } + } + } + + /** + * Closes the given session. + */ + private static void closeSession(Session session) throws IOException { + if (session.isOpen()) { + CloseReason.CloseCodes code = CloseReason.CloseCodes.NORMAL_CLOSURE; + String phrase = JakartaSocketModeClientTyrusImpl.class.getCanonicalName() + " did it"; + session.close(new CloseReason(code, phrase)); + } + } + + // ---------------------------------------------------- + + @Override + public Slack getSlack() { + return this.slack; + } + + @Override + public void setSlack(Slack slack) { + this.slack = slack; + } + + @Override + public Gson getGson() { + return this.gson; + } + + @Override + public String getAppToken() { + return this.appToken; + } + + @Override + public void setAppToken(String appToken) { + this.appToken = appToken; + } + + @Override + public boolean isAutoReconnectEnabled() { + return this.autoReconnectEnabled; + } + + @Override + public void setAutoReconnectEnabled(boolean autoReconnectEnabled) { + this.autoReconnectEnabled = autoReconnectEnabled; + } + + @Override + public boolean isSessionMonitorEnabled() { + return this.sessionMonitorEnabled; + } + + @Override + public void setSessionMonitorEnabled(boolean sessionMonitorEnabled) { + this.sessionMonitorEnabled = sessionMonitorEnabled; + } + + @Override + public Optional getSessionMonitorExecutor() { + return this.sessionMonitorExecutor; + } + + @Override + public void sendWebSocketMessage(String message) { + this.currentSession.getAsyncRemote().sendText(message); + } + + @Override + public URI getWssUri() { + return this.wssUri; + } + + @Override + public void setWssUri(URI wssUri) { + this.wssUri = wssUri; + } + + @Override + public SocketModeMessageQueue getMessageQueue() { + return this.messageQueue; + } + + @Override + public void setMessageQueue(SocketModeMessageQueue messageQueue) { + this.messageQueue = messageQueue; + } + + @Override + public ScheduledExecutorService getMessageProcessorExecutor() { + return this.messageProcessorExecutor; + } + + @Override + public void setMessageProcessorExecutor(ScheduledExecutorService executorService) { + this.messageProcessorExecutor = executorService; + } + + @Override + public void setSessionMonitorExecutor(Optional executorService) { + this.sessionMonitorExecutor = executorService; + } + + @Override + public List getWebSocketMessageListeners() { + return this.webSocketMessageListeners; + } + + @Override + public List getWebSocketErrorListeners() { + return this.webSocketErrorListeners; + } + + @Override + public List getWebSocketCloseListeners() { + return this.webSocketCloseListeners; + } + + @Override + public List> getInteractiveEnvelopeListeners() { + return this.interactiveEnvelopeListeners; + } + + @Override + public List> getSlashCommandsEnvelopeListeners() { + return this.slashCommandsEnvelopeListeners; + } + + @Override + public List> getEventsApiEnvelopeListeners() { + return this.eventsApiEnvelopeListeners; + } + +} diff --git a/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/package-info.java b/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/package-info.java new file mode 100644 index 000000000..f7e5fc3b5 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/main/java/com/slack/api/jakarta_socket_mode/package-info.java @@ -0,0 +1,4 @@ +/** + * Jakarta EE compatible WebSocket client for Socket Mode protocol + */ +package com.slack.api.jakarta_socket_mode; \ No newline at end of file diff --git a/slack-jakarta-socket-mode-client/src/test/java/config/Constants.java b/slack-jakarta-socket-mode-client/src/test/java/config/Constants.java new file mode 100644 index 000000000..4bf40b230 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/config/Constants.java @@ -0,0 +1,12 @@ +package config; + +public class Constants { + private Constants() { + } + + public static final String SKIP_UNSTABLE_TESTS = "SKIP_UNSTABLE_TESTS"; + + // Socket Mode + public static final String SLACK_SDK_TEST_SOCKET_MODE_APP_TOKEN = "SLACK_SDK_TEST_SOCKET_MODE_APP_TOKEN"; + public static final String SLACK_SDK_TEST_SOCKET_MODE_BOT_TOKEN = "SLACK_SDK_TEST_SOCKET_MODE_BOT_TOKEN"; +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/config/SlackTestConfig.java b/slack-jakarta-socket-mode-client/src/test/java/config/SlackTestConfig.java new file mode 100644 index 000000000..1792374fd --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/config/SlackTestConfig.java @@ -0,0 +1,60 @@ +package config; + +import com.slack.api.SlackConfig; +import com.slack.api.rate_limits.metrics.MetricsDatastore; +import com.slack.api.util.http.listener.HttpResponseListener; +import com.slack.api.util.json.GsonFactory; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SlackTestConfig { + + private static final SlackConfig CONFIG = new SlackConfig(); + + private final SlackConfig config; + + public MetricsDatastore getMethodsMetricsDatastore() { + return getConfig().getMethodsConfig().getMetricsDatastore(); + } + + public MetricsDatastore getAuditMetricsDatastore() { + return getConfig().getAuditConfig().getMetricsDatastore(); + } + + private SlackTestConfig(SlackConfig config) { + this.config = config; + CONFIG.getHttpClientResponseHandlers().add(new HttpResponseListener() { + @Override + public void accept(State state) { + String json = GsonFactory.createSnakeCase(CONFIG).toJson(getMethodsMetricsDatastore().getAllStats()); + log.debug("--- (API Methods Stats) ---\n" + json); + } + }); + CONFIG.getHttpClientResponseHandlers().add(new HttpResponseListener() { + @Override + public void accept(State state) { + String json = GsonFactory.createSnakeCase(CONFIG).toJson(getAuditMetricsDatastore().getAllStats()); + log.debug("--- (Audit Logs Stats) ---\n" + json); + } + }); + CONFIG.getHttpClientResponseHandlers().add(new HttpResponseListener() { + @Override + public void accept(State state) { + String json = GsonFactory.createSnakeCase(CONFIG).toJson(getAuditMetricsDatastore().getAllStats()); + log.debug("--- (Audit Logs Stats) ---\n" + json); + } + }); + } + + static { + CONFIG.setLibraryMaintainerMode(true); + CONFIG.setPrettyResponseLoggingEnabled(true); + CONFIG.setFailOnUnknownProperties(true); + CONFIG.setHttpClientReadTimeoutMillis(30000); + } + + public SlackConfig getConfig() { + return config; + } + +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/EnvelopeTest.java b/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/EnvelopeTest.java new file mode 100644 index 000000000..50732b661 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/EnvelopeTest.java @@ -0,0 +1,206 @@ +package test_locally.jakarta_socket_mode; + +import com.google.gson.Gson; +import com.slack.api.socket_mode.request.EventsApiEnvelope; +import com.slack.api.socket_mode.request.HelloMessage; +import com.slack.api.socket_mode.request.InteractiveEnvelope; +import com.slack.api.socket_mode.request.SlashCommandsEnvelope; +import com.slack.api.util.json.GsonFactory; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNotNull; + +public class EnvelopeTest { + + static final Gson GSON = GsonFactory.createSnakeCase(); + + @Test + public void hello() { + String message = "{\n" + + " \"type\": \"hello\",\n" + + " \"num_connections\": 1,\n" + + " \"debug_info\": {\n" + + " \"host\": \"applink-xxx-yyy\",\n" + + " \"build_number\": 999,\n" + + " \"approximate_connection_time\": 18060\n" + + " },\n" + + " \"connection_info\": {\n" + + " \"app_id\": \"A111\"\n" + + " }\n" + + "}\n"; + HelloMessage hello = GSON.fromJson(message, HelloMessage.class); + assertThat(hello.getType(), is("hello")); + assertThat(hello.getDebugInfo().getApproximateConnectionTime(), is(18060)); + } + + @Test + public void interactive() { + String message = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"type\": \"block_actions\",\n" + + " \"user\": {\n" + + " \"id\": \"U111\",\n" + + " \"username\": \"test-test-test\",\n" + + " \"name\": \"test-test-test\",\n" + + " \"team_id\": \"T111\"\n" + + " },\n" + + " \"api_app_id\": \"A111\",\n" + + " \"token\": \"fixed-value\",\n" + + " \"container\": {\n" + + " \"type\": \"message\",\n" + + " \"message_ts\": \"1605853634.000400\",\n" + + " \"channel_id\": \"C111\",\n" + + " \"is_ephemeral\": false\n" + + " },\n" + + " \"trigger_id\": \"111.222.xxx\",\n" + + " \"team\": {\n" + + " \"id\": \"T111\",\n" + + " \"domain\": \"test-test-test\"\n" + + " },\n" + + " \"channel\": {\n" + + " \"id\": \"C111\",\n" + + " \"name\": \"random\"\n" + + " },\n" + + " \"message\": {\n" + + " \"bot_id\": \"B111\",\n" + + " \"type\": \"message\",\n" + + " \"text\": \"This content can't be displayed.\",\n" + + " \"user\": \"U222\",\n" + + " \"ts\": \"1605853634.000400\",\n" + + " \"team\": \"T111\",\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"actions\",\n" + + " \"block_id\": \"b\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"button\",\n" + + " \"action_id\": \"a\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Click Me!\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"value\": \"underlying\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"response_url\": \"https://hooks.slack.com/actions/T111/111/xxx\",\n" + + " \"actions\": [\n" + + " {\n" + + " \"action_id\": \"a\",\n" + + " \"block_id\": \"b\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Click Me!\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"value\": \"underlying\",\n" + + " \"type\": \"button\",\n" + + " \"action_ts\": \"1605853645.582706\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"type\": \"interactive\",\n" + + " \"accepts_response_payload\": false\n" + + "}\n"; + InteractiveEnvelope envelope = GSON.fromJson(message, InteractiveEnvelope.class); + assertThat(envelope.getType(), is("interactive")); + assertNotNull(envelope.getPayload().getAsJsonObject().get("user")); + } + + @Test + public void events_api() { + String message = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"token\": \"fixed-value\",\n" + + " \"team_id\": \"T111\",\n" + + " \"api_app_id\": \"A111\",\n" + + " \"event\": {\n" + + " \"client_msg_id\": \"1748313e-912c-4942-a562-99754707692c\",\n" + + " \"type\": \"app_mention\",\n" + + " \"text\": \"<@U222> hey\",\n" + + " \"user\": \"U111\",\n" + + " \"ts\": \"1605853844.000800\",\n" + + " \"team\": \"T111\",\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"rich_text\",\n" + + " \"block_id\": \"K8xp\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"rich_text_section\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"user\",\n" + + " \"user_id\": \"U222\"\n" + + " },\n" + + " {\n" + + " \"type\": \"text\",\n" + + " \"text\": \" hey\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"channel\": \"C111\",\n" + + " \"event_ts\": \"1605853844.000800\"\n" + + " },\n" + + " \"type\": \"event_callback\",\n" + + " \"event_id\": \"Ev01ERKCFKK9\",\n" + + " \"event_time\": 1605853844,\n" + + " \"authorizations\": [\n" + + " {\n" + + " \"enterprise_id\": null,\n" + + " \"team_id\": \"T111\",\n" + + " \"user_id\": \"U222\",\n" + + " \"is_bot\": true,\n" + + " \"is_enterprise_install\": false\n" + + " }\n" + + " ],\n" + + " \"is_ext_shared_channel\": false,\n" + + " \"event_context\": \"1-app_mention-T111-C111\"\n" + + " },\n" + + " \"type\": \"events_api\",\n" + + " \"accepts_response_payload\": false,\n" + + " \"retry_attempt\": 0,\n" + + " \"retry_reason\": \"\"\n" + + "}\n"; + EventsApiEnvelope envelope = GSON.fromJson(message, EventsApiEnvelope.class); + assertThat(envelope.getType(), is("events_api")); + assertNotNull(envelope.getPayload().getAsJsonObject().get("event")); + } + + @Test + public void slash_commands() { + String message = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"token\": \"fixed-value\",\n" + + " \"team_id\": \"T111\",\n" + + " \"team_domain\": \"test-test-test\",\n" + + " \"channel_id\": \"C111\",\n" + + " \"channel_name\": \"random\",\n" + + " \"user_id\": \"U111\",\n" + + " \"user_name\": \"test-test-test\",\n" + + " \"command\": \"/hi-socket-mode\",\n" + + " \"text\": \"\",\n" + + " \"api_app_id\": \"A111\",\n" + + " \"response_url\": \"https://hooks.slack.com/commands/T111/111/xxx\",\n" + + " \"trigger_id\": \"111.222.xxx\"\n" + + " },\n" + + " \"type\": \"slash_commands\",\n" + + " \"accepts_response_payload\": true\n" + + "}\n"; + SlashCommandsEnvelope envelope = GSON.fromJson(message, SlashCommandsEnvelope.class); + assertThat(envelope.getType(), is("slash_commands")); + assertThat(envelope.getPayload().getAsJsonObject().get("team_id").getAsString(), is("T111")); + } +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/SocketModeClientTest.java b/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/SocketModeClientTest.java new file mode 100644 index 000000000..c187ef9d6 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/SocketModeClientTest.java @@ -0,0 +1,270 @@ +package test_locally.jakarta_socket_mode; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.slack.api.Slack; +import com.slack.api.SlackConfig; +import com.slack.api.jakarta_socket_mode.JakartaSocketModeClientFactory; +import com.slack.api.socket_mode.SocketModeClient; +import com.slack.api.socket_mode.listener.WebSocketMessageListener; +import com.slack.api.socket_mode.response.AckResponse; +import com.slack.api.socket_mode.response.MessagePayload; +import com.slack.api.socket_mode.response.MessageResponse; +import com.slack.api.util.json.GsonFactory; +import com.slack.api.util.thread.DaemonThreadExecutorServiceProvider; +import com.slack.api.util.thread.ExecutorServiceProvider; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import util.mock_server.MockWebApiServer; +import util.mock_server.MockWebSocketServer; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class SocketModeClientTest { + + static final Gson GSON = GsonFactory.createSnakeCase(); + static final String VALID_APP_TOKEN = "xapp-valid-123123123123123123123123123123123123"; + + MockWebApiServer webApiServer = new MockWebApiServer(); + MockWebSocketServer wsServer = new MockWebSocketServer(); + SlackConfig config = new SlackConfig(); + Slack slack = Slack.getInstance(config); + + @Before + public void setup() throws Exception { + webApiServer.start(); + wsServer.start(); + config = new SlackConfig(); + config.setMethodsEndpointUrlPrefix(webApiServer.getMethodsEndpointPrefix()); + slack = Slack.getInstance(config); + } + + @After + public void tearDown() throws Exception { + webApiServer.stop(); + wsServer.stop(); + } + + // ------------------------------------------------- + // Default implementation + // ------------------------------------------------- + + @Test + public void attributes() throws Exception { + try (SocketModeClient client = JakartaSocketModeClientFactory.create(slack, VALID_APP_TOKEN)) { + assertNotNull(client.getAppToken()); + assertNotNull(client.getMessageQueue()); + assertNotNull(client.getGson()); + assertNotNull(client.getLogger()); + assertNotNull(client.getSlack()); + assertNotNull(client.getWssUri()); + } + } + + @Test + public void connect() throws Exception { + try (SocketModeClient client = JakartaSocketModeClientFactory.create(slack, VALID_APP_TOKEN)) { + AtomicBoolean received = new AtomicBoolean(false); + client.addWebSocketMessageListener(helloListener(received)); + client.addWebSocketErrorListener(error -> { + }); + client.addWebSocketCloseListener((code, reason) -> { + }); + client.addEventsApiEnvelopeListener(envelope -> { + }); + client.addInteractiveEnvelopeListener(envelope -> { + }); + client.addSlashCommandsEnvelopeListener(envelope -> { + }); + + client.connect(); + int counter = 0; + while (!received.get() && counter < 20) { + Thread.sleep(100L); + counter++; + } + assertTrue(received.get()); + + client.disconnect(); + client.runCloseListenersAndAutoReconnectAsNecessary(1000, null); + + client.removeWebSocketMessageListener(client.getWebSocketMessageListeners().get(0)); + client.removeWebSocketErrorListener(client.getWebSocketErrorListeners().get(0)); + client.removeWebSocketCloseListener(client.getWebSocketCloseListeners().get(0)); + + client.removeEventsApiEnvelopeListener(client.getEventsApiEnvelopeListeners().get(0)); + client.removeInteractiveEnvelopeListener(client.getInteractiveEnvelopeListeners().get(0)); + client.removeSlashCommandsEnvelopeListener(client.getSlashCommandsEnvelopeListeners().get(0)); + } + } + + @Test + public void connect_with_custom_ExecutorService() throws Exception { + final AtomicBoolean called = new AtomicBoolean(false); + final AtomicBoolean called2 = new AtomicBoolean(false); + config.setExecutorServiceProvider(new ExecutorServiceProvider() { + @Override + public ExecutorService createThreadPoolExecutor(String threadGroupName, int poolSize) { + called.set(true); + return DaemonThreadExecutorServiceProvider.getInstance() + .createThreadPoolExecutor(threadGroupName, poolSize); + } + + @Override + public ScheduledExecutorService createThreadScheduledExecutor(String threadGroupName) { + called2.set(true); + return DaemonThreadExecutorServiceProvider.getInstance() + .createThreadScheduledExecutor(threadGroupName); + } + }); + Slack slack = Slack.getInstance(config); + try (SocketModeClient client = JakartaSocketModeClientFactory.create(slack, VALID_APP_TOKEN)) { + AtomicBoolean received = new AtomicBoolean(false); + client.addWebSocketMessageListener(helloListener(received)); + client.addWebSocketErrorListener(error -> { + }); + client.addWebSocketCloseListener((code, reason) -> { + }); + client.addEventsApiEnvelopeListener(envelope -> { + }); + client.addInteractiveEnvelopeListener(envelope -> { + }); + client.addSlashCommandsEnvelopeListener(envelope -> { + }); + + client.connect(); + int counter = 0; + while (!received.get() && counter < 20) { + Thread.sleep(100L); + counter++; + } + assertTrue(received.get()); + + client.disconnect(); + client.runCloseListenersAndAutoReconnectAsNecessary(1000, null); + + client.removeWebSocketMessageListener(client.getWebSocketMessageListeners().get(0)); + client.removeWebSocketErrorListener(client.getWebSocketErrorListeners().get(0)); + client.removeWebSocketCloseListener(client.getWebSocketCloseListeners().get(0)); + + client.removeEventsApiEnvelopeListener(client.getEventsApiEnvelopeListeners().get(0)); + client.removeInteractiveEnvelopeListener(client.getInteractiveEnvelopeListeners().get(0)); + client.removeSlashCommandsEnvelopeListener(client.getSlashCommandsEnvelopeListeners().get(0)); + } + assertTrue(called.get()); + assertTrue(called2.get()); + } + + @Test + public void maintainCurrentSession() throws Exception { + try (SocketModeClient client = JakartaSocketModeClientFactory.create(slack, VALID_APP_TOKEN)) { + client.connect(); + client.maintainCurrentSession(); + } + } + + @Test + public void connectToNewEndpoint() throws Exception { + try (SocketModeClient client = JakartaSocketModeClientFactory.create(slack, VALID_APP_TOKEN)) { + AtomicBoolean received = new AtomicBoolean(false); + client.addWebSocketMessageListener(helloListener(received)); + client.connect(); + client.disconnect(); + client.connectToNewEndpoint(); + int counter = 0; + while (!received.get() && counter < 20) { + Thread.sleep(100L); + counter++; + } + assertTrue(received.get()); + } + } + + @Test + public void sendSocketModeResponse() throws Exception { + try (SocketModeClient client = JakartaSocketModeClientFactory.create(slack, VALID_APP_TOKEN)) { + AtomicBoolean received = new AtomicBoolean(false); + client.addWebSocketMessageListener(helloListener(received)); + client.connect(); + int counter = 0; + while (!received.get() && counter < 20) { + Thread.sleep(100L); + counter++; + } + assertTrue(received.get()); + client.sendSocketModeResponse(AckResponse.builder().envelopeId("xxx").build()); + client.sendSocketModeResponse(MessageResponse.builder() + .envelopeId("xxx") + .payload(MessagePayload.builder().text("Hi there!").build()) + .build()); + } + } + + @Test + public void messageReceiver() throws Exception { + try (SocketModeClient client = JakartaSocketModeClientFactory.create(slack, VALID_APP_TOKEN)) { + AtomicBoolean helloReceived = new AtomicBoolean(false); + AtomicBoolean received = new AtomicBoolean(false); + client.addWebSocketMessageListener(helloListener(helloReceived)); + client.addWebSocketMessageListener(envelopeListener(received)); + client.connect(); + int counter = 0; + while (!received.get() && counter < 50) { + Thread.sleep(100L); + counter++; + } + assertTrue(helloReceived.get()); + assertTrue(received.get()); + } + } + + // ------------------------------------------------- + + private static Optional getEnvelopeType(String message) { + JsonElement msg = GSON.fromJson(message, JsonElement.class); + if (msg != null && msg.isJsonObject()) { + JsonElement typeElem = msg.getAsJsonObject().get("type"); + if (typeElem != null && typeElem.isJsonPrimitive()) { + return Optional.of(typeElem.getAsString()); + } + } + return Optional.empty(); + } + + private static WebSocketMessageListener helloListener(AtomicBoolean received) { + return message -> { + Optional type = getEnvelopeType(message); + if (type.isPresent()) { + if (type.get().equals("hello")) { + received.set(true); + } + } + }; + } + + private static List MESSAGE_TYPES = Arrays.asList( + "events", + "interactive", + "slash_commands" + ); + + private static WebSocketMessageListener envelopeListener(AtomicBoolean received) { + return message -> { + Optional type = getEnvelopeType(message); + if (type.isPresent()) { + if (MESSAGE_TYPES.contains(type.get())) { + received.set(true); + } + } + }; + } +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/SocketModeClient_Proxies_Test.java b/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/SocketModeClient_Proxies_Test.java new file mode 100644 index 000000000..46c659cda --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/test_locally/jakarta_socket_mode/SocketModeClient_Proxies_Test.java @@ -0,0 +1,109 @@ +package test_locally.jakarta_socket_mode; + +import com.slack.api.Slack; +import com.slack.api.SlackConfig; +import com.slack.api.jakarta_socket_mode.JakartaSocketModeClientFactory; +import com.slack.api.socket_mode.SocketModeClient; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Credentials; +import org.eclipse.jetty.proxy.ConnectHandler; +import org.eclipse.jetty.proxy.ProxyServlet; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import util.mock_server.MockWebApiServer; +import util.mock_server.PortProvider; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +public class SocketModeClient_Proxies_Test { + + static final String VALID_APP_TOKEN = "xapp-valid-123123123123123123123123123123123123"; + + MockWebApiServer webApiServer = new MockWebApiServer(); + SlackConfig config = new SlackConfig(); + + static Server proxyServer = new Server(); + static ServerConnector proxyServerConnector = new ServerConnector(proxyServer); + static Integer proxyServerPort; + + AtomicInteger proxyCallCount = new AtomicInteger(0); + + @Before + public void setUp() throws Exception { + webApiServer.start(); + config.setMethodsEndpointUrlPrefix(webApiServer.getMethodsEndpointPrefix()); + + // https://github.com/eclipse/jetty.project/blob/jetty-9.2.30.v20200428/examples/embedded/src/main/java/org/eclipse/jetty/embedded/ProxyServer.java + proxyServerPort = PortProvider.getPort(SocketModeClient_Proxies_Test.class.getName()); + proxyServerConnector.setPort(proxyServerPort); + proxyServer.addConnector(proxyServerConnector); + ConnectHandler proxy = new ConnectHandler() { + @Override + public void handle(String target, Request br, HttpServletRequest request, HttpServletResponse res) + throws ServletException, IOException { + log.info("Proxy server handles a new connection (target: {})", target); + super.handle(target, br, request, res); + if (res.getStatus() != 407) { + proxyCallCount.incrementAndGet(); + } + } + + @Override + protected boolean handleAuthentication(HttpServletRequest request, HttpServletResponse response, String address) { + for (String name : Collections.list(request.getHeaderNames())) { + log.info("{}: {}", name, request.getHeader(name)); + if (name.toLowerCase(Locale.ENGLISH).equals("proxy-authorization")) { + return request.getHeader(name).equals("Basic bXktdXNlcm5hbWU6bXktcGFzc3dvcmQ="); + } + } + return false; + } + }; + proxyServer.setHandler(proxy); + ServletContextHandler context = new ServletContextHandler(proxy, "/", ServletContextHandler.SESSIONS); + ServletHolder proxyServlet = new ServletHolder(ProxyServlet.class); + context.addServlet(proxyServlet, "/*"); + proxyServer.start(); + + config.setProxyUrl("http://127.0.0.1:" + proxyServerPort); + + Map proxyHeaders = new HashMap<>(); + String username = "my-username"; + String password = "my-password"; + proxyHeaders.put("Proxy-Authorization", Credentials.basic(username, password)); + config.setProxyHeaders(proxyHeaders); + } + + @After + public void tearDown() throws Exception { + webApiServer.stop(); + + proxyServer.removeConnector(proxyServerConnector); + proxyServer.stop(); + } + + @Test + public void proxyAuth() throws Exception { + SlackConfig config = new SlackConfig(); + config.setMethodsEndpointUrlPrefix(webApiServer.getMethodsEndpointPrefix()); + config.setProxyUrl("http://my-username:my-password@localhost:" + proxyServerPort + "/"); + Slack slack = Slack.getInstance(config); + SocketModeClient client = JakartaSocketModeClientFactory.create(slack, VALID_APP_TOKEN); + client.close(); + } +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/test_with_remote_apis/jakarta_socket_mode/SimpleSocketModeApp.java b/slack-jakarta-socket-mode-client/src/test/java/test_with_remote_apis/jakarta_socket_mode/SimpleSocketModeApp.java new file mode 100644 index 000000000..61a97a976 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/test_with_remote_apis/jakarta_socket_mode/SimpleSocketModeApp.java @@ -0,0 +1,95 @@ +package test_with_remote_apis.jakarta_socket_mode; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.slack.api.Slack; +import com.slack.api.jakarta_socket_mode.JakartaSocketModeClientFactory; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.methods.response.views.ViewsOpenResponse; +import com.slack.api.socket_mode.SocketModeClient; +import com.slack.api.socket_mode.response.AckResponse; +import com.slack.api.socket_mode.response.MapResponse; +import com.slack.api.socket_mode.response.MessagePayload; +import com.slack.api.socket_mode.response.MessageResponse; +import config.Constants; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.Map; + +import static com.slack.api.model.block.Blocks.*; +import static com.slack.api.model.block.composition.BlockCompositions.plainText; +import static com.slack.api.model.block.element.BlockElements.*; +import static com.slack.api.model.view.Views.*; + +@Slf4j +public class SimpleSocketModeApp { + + public static void main(String[] args) throws Exception { + String botToken = System.getenv(Constants.SLACK_SDK_TEST_SOCKET_MODE_BOT_TOKEN); + Slack slack = Slack.getInstance(); + MethodsClient apiClient = slack.methods(botToken); + ChatPostMessageResponse chatPostMessageResponse = apiClient.chatPostMessage(r -> r + // Invite this app to #random beforehand + .channel("#random") + .blocks(asBlocks( + actions(a -> a.blockId("b").elements(asElements( + button(b -> b.actionId("a").text(plainText("Click Me!")).value("underlying")) + ))) + ))); + log.info("chat.postMessage: {}", chatPostMessageResponse); + + String appToken = System.getenv(Constants.SLACK_SDK_TEST_SOCKET_MODE_APP_TOKEN); + SocketModeClient socketModeClient = JakartaSocketModeClientFactory.create(slack, appToken); + socketModeClient.addEventsApiEnvelopeListener(req -> { + socketModeClient.sendSocketModeResponse(new AckResponse(req.getEnvelopeId())); + }); + socketModeClient.addSlashCommandsEnvelopeListener(req -> { + socketModeClient.sendSocketModeResponse(MessageResponse.builder() + .envelopeId(req.getEnvelopeId()) + .payload(MessagePayload.builder().text("Hi!").build()) + .build() + ); + + try { + ViewsOpenResponse newModal = apiClient.viewsOpen(r -> r + .triggerId(req.getPayload().getAsJsonObject().get("trigger_id").getAsString()) + .view(view(v -> v + .type("modal") + .callbackId("modal-id") + .title(viewTitle(vt -> vt.type("plain_text").text("My App"))) + .close(viewClose(vc -> vc.type("plain_text").text("Close"))) + .submit(viewSubmit(vs -> vs.type("plain_text").text("Submit"))) + .blocks(asBlocks(input(i -> i + .blockId("agenda-block") + .element(plainTextInput(pti -> pti.actionId("agenda-action").multiline(true))) + .label(plainText(pt -> pt.text("Detailed Agenda").emoji(true))) + ))) + )) + ); + } catch (Exception ex) { + log.error("Failed to open a modal: {}", ex.getMessage()); + } + }); + socketModeClient.addInteractiveEnvelopeListener(req -> { + JsonObject requestPayload = req.getPayload().getAsJsonObject(); + if (requestPayload.get("view") != null) { + JsonObject view = requestPayload.get("view").getAsJsonObject(); + JsonElement callbackId = view.get("callback_id"); + if (callbackId != null && callbackId.getAsString().equals("modal-id")) { + Map responsePayload = new HashMap<>(); + responsePayload.put("response_action", "errors"); + Map errors = new HashMap<>(); + errors.put("agenda-block", "Something is wrong!"); + responsePayload.put("errors", errors); + socketModeClient.sendSocketModeResponse(new MapResponse(req.getEnvelopeId(), responsePayload)); + return; + } + } + socketModeClient.sendSocketModeResponse(new AckResponse(req.getEnvelopeId())); + }); + socketModeClient.connect(); + Thread.sleep(Long.MAX_VALUE); + } +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/test_with_remote_apis/jakarta_socket_mode/SocketModeSnippet.java b/slack-jakarta-socket-mode-client/src/test/java/test_with_remote_apis/jakarta_socket_mode/SocketModeSnippet.java new file mode 100644 index 000000000..790b88901 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/test_with_remote_apis/jakarta_socket_mode/SocketModeSnippet.java @@ -0,0 +1,46 @@ +package test_with_remote_apis.jakarta_socket_mode; + +import com.slack.api.jakarta_socket_mode.JakartaSocketModeClientFactory; +import com.slack.api.socket_mode.SocketModeClient; +import com.slack.api.socket_mode.request.EventsApiEnvelope; +import com.slack.api.socket_mode.response.AckResponse; +import com.slack.api.socket_mode.response.SocketModeResponse; + +import java.io.IOException; + +public class SocketModeSnippet { + + public static void main(String[] args) throws IOException { + String appLevelToken = "xapp-A111-222-xxx"; + + // Issue a new WSS URL and set the value to the client + try (SocketModeClient client = JakartaSocketModeClientFactory.create(appLevelToken)) { + // SocketModeClient has #close() method + + // Add a listener function to handle all raw WebSocket text messages + // You can handle not only envelopes but also any others such as "hello" messages. + client.addWebSocketMessageListener((String message) -> { + // TODO: Do something with the raw WebSocket text message + }); + + client.addWebSocketErrorListener((Throwable reason) -> { + // TODO: Do something with a thrown exception + }); + + // Add a listener function that handles only type: events envelopes + client.addEventsApiEnvelopeListener((EventsApiEnvelope envelope) -> { + // TODO: Do something with the payload + + // Acknowledge the request + SocketModeResponse ack = AckResponse.builder().envelopeId(envelope.getEnvelopeId()).build(); + client.sendSocketModeResponse(ack); + }); + + client.connect(); // Start receiving messages from the Socket Mode server + + client.disconnect(); // Disconnect from the server + + client.connectToNewEndpoint(); // Issue a new WSS URL and connects to the URL + } + } +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockSocketMode.java b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockSocketMode.java new file mode 100644 index 000000000..25aaeae0b --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockSocketMode.java @@ -0,0 +1,255 @@ +package util.mock_server; + +import com.slack.api.util.thread.DaemonThreadExecutorServiceProvider; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.eclipse.jetty.websocket.api.WebSocketAdapter; + +import java.io.IOException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class MockSocketMode extends WebSocketAdapter { + private CountDownLatch closureLatch = new CountDownLatch(1); + + private CopyOnWriteArrayList activeSessions = new CopyOnWriteArrayList<>(); + private ScheduledExecutorService service = DaemonThreadExecutorServiceProvider.getInstance() + .createThreadScheduledExecutor(MockSocketMode.class.getCanonicalName()); + + public MockSocketMode() { + super(); + service.scheduleAtFixedRate(() -> { + List stoleSessions = new ArrayList<>(); + for (Session session : activeSessions) { + if (session.isOpen()) { + try { + session.getRemote().sendString(getRandomEnvelope()); + } catch (IOException e) { + log.error("Failed to send a message", e); + } + } else { + stoleSessions.add(session); + } + } + activeSessions.removeAll(stoleSessions); + + }, 200L, 100L, TimeUnit.MILLISECONDS); + } + + @Override + public void onWebSocketConnect(Session session) { + super.onWebSocketConnect(session); + this.activeSessions.add(session); + log.info("connected: {}", session); + try { + this.getRemote().sendString("{\n" + + " \"type\": \"hello\",\n" + + " \"num_connections\": 1,\n" + + " \"debug_info\": {\n" + + " \"host\": \"applink-xxx-yyy\",\n" + + " \"build_number\": 999,\n" + + " \"approximate_connection_time\": 18060\n" + + " },\n" + + " \"connection_info\": {\n" + + " \"app_id\": \"A111\"\n" + + " }\n" + + "}"); + } catch (IOException e) { + log.error("Failed to send hello message", e); + } + } + + @Override + public void onWebSocketText(String message) { + super.onWebSocketText(message); + log.info("text: {}", message); + if (message.toLowerCase(Locale.US).contains("bye")) { + getSession().close(StatusCode.NORMAL, "Thanks"); + } + } + + @Override + public void onWebSocketClose(int statusCode, String reason) { + super.onWebSocketClose(statusCode, reason); + log.info("closed: (code: {}, reason: {})", statusCode, reason); + closureLatch.countDown(); + } + + @Override + public void onWebSocketError(Throwable cause) { + super.onWebSocketError(cause); + cause.printStackTrace(System.err); + } + + String interactiveEnvelope = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"type\": \"block_actions\",\n" + + " \"user\": {\n" + + " \"id\": \"U111\",\n" + + " \"username\": \"test-test-test\",\n" + + " \"name\": \"test-test-test\",\n" + + " \"team_id\": \"T111\"\n" + + " },\n" + + " \"api_app_id\": \"A111\",\n" + + " \"token\": \"fixed-value\",\n" + + " \"container\": {\n" + + " \"type\": \"message\",\n" + + " \"message_ts\": \"1605853634.000400\",\n" + + " \"channel_id\": \"C111\",\n" + + " \"is_ephemeral\": false\n" + + " },\n" + + " \"trigger_id\": \"111.222.xxx\",\n" + + " \"team\": {\n" + + " \"id\": \"T111\",\n" + + " \"domain\": \"test-test-test\"\n" + + " },\n" + + " \"channel\": {\n" + + " \"id\": \"C111\",\n" + + " \"name\": \"random\"\n" + + " },\n" + + " \"message\": {\n" + + " \"bot_id\": \"B111\",\n" + + " \"type\": \"message\",\n" + + " \"text\": \"This content can't be displayed.\",\n" + + " \"user\": \"U222\",\n" + + " \"ts\": \"1605853634.000400\",\n" + + " \"team\": \"T111\",\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"actions\",\n" + + " \"block_id\": \"b\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"button\",\n" + + " \"action_id\": \"a\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Click Me!\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"value\": \"underlying\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"response_url\": \"https://hooks.slack.com/actions/T111/111/xxx\",\n" + + " \"actions\": [\n" + + " {\n" + + " \"action_id\": \"a\",\n" + + " \"block_id\": \"b\",\n" + + " \"text\": {\n" + + " \"type\": \"plain_text\",\n" + + " \"text\": \"Click Me!\",\n" + + " \"emoji\": true\n" + + " },\n" + + " \"value\": \"underlying\",\n" + + " \"type\": \"button\",\n" + + " \"action_ts\": \"1605853645.582706\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"type\": \"interactive\",\n" + + " \"accepts_response_payload\": false\n" + + "}\n"; + + String eventsEnvelope = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"token\": \"fixed-value\",\n" + + " \"team_id\": \"T111\",\n" + + " \"api_app_id\": \"A111\",\n" + + " \"event\": {\n" + + " \"client_msg_id\": \"1748313e-912c-4942-a562-99754707692c\",\n" + + " \"type\": \"app_mention\",\n" + + " \"text\": \"<@U222> hey\",\n" + + " \"user\": \"U111\",\n" + + " \"ts\": \"1605853844.000800\",\n" + + " \"team\": \"T111\",\n" + + " \"blocks\": [\n" + + " {\n" + + " \"type\": \"rich_text\",\n" + + " \"block_id\": \"K8xp\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"rich_text_section\",\n" + + " \"elements\": [\n" + + " {\n" + + " \"type\": \"user\",\n" + + " \"user_id\": \"U222\"\n" + + " },\n" + + " {\n" + + " \"type\": \"text\",\n" + + " \"text\": \" hey\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"channel\": \"C111\",\n" + + " \"event_ts\": \"1605853844.000800\"\n" + + " },\n" + + " \"type\": \"event_callback\",\n" + + " \"event_id\": \"Ev01ERKCFKK9\",\n" + + " \"event_time\": 1605853844,\n" + + " \"authorizations\": [\n" + + " {\n" + + " \"enterprise_id\": null,\n" + + " \"team_id\": \"T111\",\n" + + " \"user_id\": \"U222\",\n" + + " \"is_bot\": true,\n" + + " \"is_enterprise_install\": false\n" + + " }\n" + + " ],\n" + + " \"is_ext_shared_channel\": false,\n" + + " \"event_context\": \"1-app_mention-T111-C111\"\n" + + " },\n" + + " \"type\": \"events_api\",\n" + + " \"accepts_response_payload\": false,\n" + + " \"retry_attempt\": 0,\n" + + " \"retry_reason\": \"\"\n" + + "}\n"; + + String commandEnvelope = "{\n" + + " \"envelope_id\": \"xxx-11-222-yyy-zzz\",\n" + + " \"payload\": {\n" + + " \"token\": \"fixed-value\",\n" + + " \"team_id\": \"T111\",\n" + + " \"team_domain\": \"test-test-test\",\n" + + " \"channel_id\": \"C111\",\n" + + " \"channel_name\": \"random\",\n" + + " \"user_id\": \"U111\",\n" + + " \"user_name\": \"test-test-test\",\n" + + " \"command\": \"/hi-socket-mode\",\n" + + " \"text\": \"\",\n" + + " \"api_app_id\": \"A111\",\n" + + " \"response_url\": \"https://hooks.slack.com/commands/T111/111/xxx\",\n" + + " \"trigger_id\": \"111.222.xxx\"\n" + + " },\n" + + " \"type\": \"slash_commands\",\n" + + " \"accepts_response_payload\": true\n" + + "}\n"; + + List envelopes = Arrays.asList( + interactiveEnvelope, + eventsEnvelope, + commandEnvelope + ); + + private SecureRandom random = new SecureRandom(); + + private String getRandomEnvelope() { + return envelopes.get(random.nextInt(envelopes.size())); + } +} \ No newline at end of file diff --git a/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebApi.java b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebApi.java new file mode 100644 index 000000000..b52807a10 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebApi.java @@ -0,0 +1,67 @@ +package util.mock_server; + +import lombok.extern.slf4j.Slf4j; + +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.stream.Collectors; + +@WebServlet +@Slf4j +public class MockWebApi extends HttpServlet { + + public static final String VALID_TOKEN_PREFIX = "xapp-valid"; + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + try (InputStream is = req.getInputStream(); + InputStreamReader isr = new InputStreamReader(is); + BufferedReader br = new BufferedReader(isr)) { + String requestBody = br.lines().collect(Collectors.joining()); + log.info("request body: {}", requestBody); + } + String authorizationHeader = req.getHeader("Authorization"); + if (authorizationHeader != null + && !authorizationHeader.trim().isEmpty() + && authorizationHeader.startsWith("Bearer " + VALID_TOKEN_PREFIX)) { + String methodName = req.getRequestURI().replaceFirst("^/api/", ""); + if (methodName.equals("apps.connections.open")) { + resp.setStatus(200); + String port = System.getProperty(MockWebSocketServer.WEB_SOCKET_SERVER_PORT); + resp.getWriter().write("{\"ok\":true,\"url\":\"ws:\\/\\/127.0.0.1:" + port + "\\/\"}"); + resp.setContentType("application/json"); + return; + } + if (methodName.equals("auth.test")) { + String body = "{\n" + + " \"ok\": true,\n" + + " \"url\": \"https://java-slack-sdk-test.slack.com/\",\n" + + " \"team\": \"java-slack-sdk-test\",\n" + + " \"user\": \"test_user\",\n" + + " \"team_id\": \"T1234567\",\n" + + " \"user_id\": \"U1234567\",\n" + + " \"bot_id\": \"B12345678\",\n" + + " \"enterprise_id\": \"E12345678\",\n" + + " \"error\": \"\"\n" + + "}"; + resp.setStatus(200); + resp.getWriter().write(body); + resp.setContentType("application/json"); + return; + } + } else if (!authorizationHeader.startsWith("Bearer " + VALID_TOKEN_PREFIX)) { + resp.setStatus(200); + resp.getWriter().write("{\"ok\":false,\"error\":\"invalid_auth\"}"); + resp.setContentType("application/json"); + return; + } + resp.setStatus(404); + resp.getWriter().write("Not Found"); + } +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebApiServer.java b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebApiServer.java new file mode 100644 index 000000000..0a5fb4820 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebApiServer.java @@ -0,0 +1,51 @@ +package util.mock_server; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletHandler; + +import java.net.SocketException; + +public class MockWebApiServer { + + private int port; + private Server server; + + public MockWebApiServer() { + this(PortProvider.getPort(MockWebApiServer.class.getName())); + } + + public MockWebApiServer(int port) { + setup(port); + } + + private void setup(int port) { + this.port = port; + this.server = new Server(this.port); + ServletHandler handler = new ServletHandler(); + server.setHandler(handler); + handler.addServletWithMapping(MockWebApi.class, "/*"); + } + + public String getMethodsEndpointPrefix() { + return "http://127.0.0.1:" + port + "/api/"; + } + + public void start() throws Exception { + int retryCount = 0; + while (retryCount < 5) { + try { + server.start(); + return; + } catch (SocketException e) { + // java.net.SocketException: Permission denied may arise + // only on the GitHub Actions environment. + setup(PortProvider.getPort(MockWebApiServer.class.getName())); + retryCount++; + } + } + } + + public void stop() throws Exception { + server.stop(); + } +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebSocketServer.java b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebSocketServer.java new file mode 100644 index 000000000..f42a62eda --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/MockWebSocketServer.java @@ -0,0 +1,48 @@ +package util.mock_server; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.websocket.server.NativeWebSocketServletContainerInitializer; +import org.eclipse.jetty.websocket.server.WebSocketUpgradeFilter; + +import javax.servlet.ServletException; + +public class MockWebSocketServer { + + public static final String WEB_SOCKET_SERVER_PORT = "WEB_SOCKET_SERVER_PORT"; + + private final Server server; + + public MockWebSocketServer() { + server = new Server(); + ServerConnector connector = new ServerConnector(server); + int port = PortProvider.getPort(MockWebSocketServer.class.getName()); + System.setProperty(WEB_SOCKET_SERVER_PORT, String.valueOf(port)); + connector.setPort(port); + server.addConnector(connector); + + ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); + context.setContextPath("/"); + server.setHandler(context); + + NativeWebSocketServletContainerInitializer initializer = new NativeWebSocketServletContainerInitializer(); + initializer.getDefaultFrom(context.getServletContext()).addMapping("/*", MockSocketMode.class); + + try { + WebSocketUpgradeFilter upgradeFilter = WebSocketUpgradeFilter.configureContext(context); + context.setAttribute(WebSocketUpgradeFilter.class.getName() + ".SCI", upgradeFilter); + } catch (ServletException e) { + throw new RuntimeException(e); + } + } + + public void start() throws Exception { + server.start(); + } + + public void stop() throws Exception { + server.stop(); + } + +} diff --git a/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/PortProvider.java b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/PortProvider.java new file mode 100644 index 000000000..a65e0c683 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/java/util/mock_server/PortProvider.java @@ -0,0 +1,41 @@ +package util.mock_server; + +import java.io.IOException; +import java.net.Socket; +import java.security.SecureRandom; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class PortProvider { + + private PortProvider() { + } + + private static final int MINIMUM = 1024; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final ConcurrentMap PORTS = new ConcurrentHashMap<>(); + + public static int getPort(String name) { + return PORTS.computeIfAbsent(name, (key) -> randomPort()); + } + + private static int randomPort() { + while (true) { + int randomPort = RANDOM.nextInt(9999); + if (randomPort < MINIMUM) { + randomPort += MINIMUM; + } + if (isAvailable(randomPort)) { + return randomPort; + } + } + } + + private static boolean isAvailable(int port) { + try (Socket ignored = new Socket("127.0.0.1", port)) { + return false; + } catch (IOException ignored) { + return true; + } + } +} diff --git a/slack-jakarta-socket-mode-client/src/test/resources/logback.xml b/slack-jakarta-socket-mode-client/src/test/resources/logback.xml new file mode 100644 index 000000000..452f1d118 --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + logs/console.log + + %date %level [%thread] %logger{64} %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/slack-jakarta-socket-mode-client/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/slack-jakarta-socket-mode-client/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..ca6ee9cea --- /dev/null +++ b/slack-jakarta-socket-mode-client/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file