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