From f65dc3d2d995745c1c41cb9492136758d5d82652 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 17 Oct 2024 12:34:24 +0900 Subject: [PATCH] Add Agents & Assistant support (#1372) --- .../samples/AssistantEventListenerApp.java | 5 +- .../java/samples/AssistantInteractionApp.java | 114 +++++++++ .../test/java/samples/AssistantSimpleApp.java | 84 +++++++ .../src/main/java/com/slack/api/bolt/App.java | 21 +- .../java/com/slack/api/bolt/AppConfig.java | 3 + .../bolt/context/builtin/EventContext.java | 107 +++++++- .../bolt/handler/AssistantEventHandler.java | 21 ++ .../bolt/middleware/builtin/Assistant.java | 224 +++++++++++++++++ .../builtin/IgnoringSelfEvents.java | 11 + .../bolt/request/builtin/EventRequest.java | 58 ++++- .../AssistantThreadContextService.java | 13 + .../DefaultAssistantThreadContextService.java | 86 +++++++ .../api/bolt/util/EventsApiPayloadParser.java | 21 ++ .../test_locally/app/EventAssistantTest.java | 230 ++++++++++++++++++ codecov.yml | 2 +- json-logs/samples/events/MessagePayload.json | 3 + metadata/web-api/rate_limit_tiers.json | 2 +- .../api/audit/impl/AsyncAuditRateLimiter.java | 7 +- .../MethodsCustomRateLimitResolver.java | 15 ++ .../api/methods/MethodsRateLimitTier.java | 6 + .../slack/api/methods/MethodsRateLimits.java | 2 +- .../methods/impl/AsyncMethodsClientImpl.java | 5 +- .../methods/impl/AsyncMethodsRateLimiter.java | 26 ++ .../methods/impl/AsyncRateLimitExecutor.java | 3 + .../slack/api/rate_limits/RateLimiter.java | 2 + .../api/rate_limits/WaitTimeCalculator.java | 8 + .../api/scim/impl/AsyncSCIMRateLimiter.java | 7 +- .../api/scim2/impl/AsyncSCIM2RateLimiter.java | 7 +- .../api/methods/CustomRateLimitsTest.java | 5 + .../test_locally/api/methods/TeamTest.java | 5 + .../methods_admin_api/AdminApiAsyncTest.java | 5 + .../api/model/assistant/SuggestedPrompt.java | 4 + .../slack/api/model/event/MessageEvent.java | 2 + .../model/assistant/SuggestedPromptTest.java | 27 ++ 34 files changed, 1124 insertions(+), 17 deletions(-) create mode 100644 bolt-socket-mode/src/test/java/samples/AssistantInteractionApp.java create mode 100644 bolt-socket-mode/src/test/java/samples/AssistantSimpleApp.java create mode 100644 bolt/src/main/java/com/slack/api/bolt/handler/AssistantEventHandler.java create mode 100644 bolt/src/main/java/com/slack/api/bolt/middleware/builtin/Assistant.java create mode 100644 bolt/src/main/java/com/slack/api/bolt/service/AssistantThreadContextService.java create mode 100644 bolt/src/main/java/com/slack/api/bolt/service/builtin/DefaultAssistantThreadContextService.java create mode 100644 bolt/src/test/java/test_locally/app/EventAssistantTest.java create mode 100644 slack-api-model/src/test/java/test_locally/api/model/assistant/SuggestedPromptTest.java diff --git a/bolt-socket-mode/src/test/java/samples/AssistantEventListenerApp.java b/bolt-socket-mode/src/test/java/samples/AssistantEventListenerApp.java index 07ff296ca..397250929 100644 --- a/bolt-socket-mode/src/test/java/samples/AssistantEventListenerApp.java +++ b/bolt-socket-mode/src/test/java/samples/AssistantEventListenerApp.java @@ -34,7 +34,6 @@ public static void main(String[] args) throws Exception { ctx.client().assistantThreadsSetSuggestedPrompts(r -> r .channelId(channelId) .threadTs(threadTs) - .title("How are you?") .prompts(Collections.singletonList(new SuggestedPrompt("What does SLACK stand for?"))) ); } catch (Exception e) { @@ -71,7 +70,7 @@ public static void main(String[] args) throws Exception { .text("Here you are!") ); } catch (Exception e) { - ctx.logger.error("Failed to handle assistant thread started event: {e}", e); + ctx.logger.error("Failed to handle assistant user message event: {e}", e); try { ctx.client().chatPostMessage(r -> r .channel(channelId) @@ -112,7 +111,7 @@ public static void main(String[] args) throws Exception { .text("Your files do not have any issues!") ); } catch (Exception e) { - ctx.logger.error("Failed to handle assistant thread started event: {e}", e); + ctx.logger.error("Failed to handle assistant user message event: {e}", e); try { ctx.client().chatPostMessage(r -> r .channel(channelId) diff --git a/bolt-socket-mode/src/test/java/samples/AssistantInteractionApp.java b/bolt-socket-mode/src/test/java/samples/AssistantInteractionApp.java new file mode 100644 index 000000000..e205883da --- /dev/null +++ b/bolt-socket-mode/src/test/java/samples/AssistantInteractionApp.java @@ -0,0 +1,114 @@ +package samples; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.middleware.builtin.Assistant; +import com.slack.api.bolt.socket_mode.SocketModeApp; +import com.slack.api.model.Message; +import com.slack.api.model.event.AppMentionEvent; +import com.slack.api.model.event.MessageEvent; + +import java.security.SecureRandom; +import java.util.*; + +import static com.slack.api.model.block.Blocks.actions; +import static com.slack.api.model.block.Blocks.section; +import static com.slack.api.model.block.composition.BlockCompositions.plainText; +import static com.slack.api.model.block.element.BlockElements.button; + +public class AssistantInteractionApp { + + public static void main(String[] args) throws Exception { + String botToken = System.getenv("SLACK_BOT_TOKEN"); + String appToken = System.getenv("SLACK_APP_TOKEN"); + + App app = new App(AppConfig.builder() + .singleTeamBotToken(botToken) + .ignoringSelfAssistantMessageEventsEnabled(false) + .build() + ); + + Assistant assistant = new Assistant(app.executorService()); + + assistant.threadStarted((req, ctx) -> { + try { + ctx.say(r -> r + .text("Hi, how can I help you today?") + .blocks(Arrays.asList( + section(s -> s.text(plainText("Hi, how I can I help you today?"))), + actions(a -> a.elements(Collections.singletonList( + button(b -> b.actionId("assistant-generate-numbers").text(plainText("Generate numbers"))) + ))) + )) + ); + } catch (Exception e) { + ctx.logger.error("Failed to handle assistant thread started event: {e}", e); + } + }); + + app.blockAction("assistant-generate-numbers", (req, ctx) -> { + app.executorService().submit(() -> { + Map eventPayload = new HashMap<>(); + eventPayload.put("num", 20); + try { + ctx.client().chatPostMessage(r -> r + .channel(req.getPayload().getChannel().getId()) + .threadTs(req.getPayload().getMessage().getThreadTs()) + .text("OK, I will generate numbers for you!") + .metadata(new Message.Metadata("assistant-generate-numbers", eventPayload)) + ); + } catch (Exception e) { + ctx.logger.error("Failed to post a bot message: {e}", e); + } + }); + return ctx.ack(); + }); + + assistant.botMessage((req, ctx) -> { + if (req.getEvent().getMetadata() != null + && req.getEvent().getMetadata().getEventType().equals("assistant-generate-numbers")) { + try { + ctx.setStatus("is typing..."); + Double num = (Double) req.getEvent().getMetadata().getEventPayload().get("num"); + Set numbers = new HashSet<>(); + SecureRandom random = new SecureRandom(); + while (numbers.size() < num) { + numbers.add(String.valueOf(random.nextInt(100))); + } + Thread.sleep(1000L); + ctx.say(r -> r.text("Her you are: " + String.join(", ", numbers))); + } catch (Exception e) { + ctx.logger.error("Failed to handle assistant bot message event: {e}", e); + } + } + }); + + assistant.userMessage((req, ctx) -> { + try { + ctx.setStatus("is typing..."); + ctx.say(r -> r.text("Sorry, I couldn't understand your comment.")); + } catch (Exception e) { + ctx.logger.error("Failed to handle assistant user message event: {e}", e); + try { + ctx.say(r -> r.text(":warning: Sorry, something went wrong during processing your request!")); + } catch (Exception ee) { + ctx.logger.error("Failed to inform the error to the end-user: {ee}", ee); + } + } + }); + + + app.assistant(assistant); + + app.event(MessageEvent.class, (req, ctx) -> { + return ctx.ack(); + }); + + app.event(AppMentionEvent.class, (req, ctx) -> { + ctx.say("You can help you at our 1:1 DM!"); + return ctx.ack(); + }); + + new SocketModeApp(appToken, app).start(); + } +} diff --git a/bolt-socket-mode/src/test/java/samples/AssistantSimpleApp.java b/bolt-socket-mode/src/test/java/samples/AssistantSimpleApp.java new file mode 100644 index 000000000..cdcc21296 --- /dev/null +++ b/bolt-socket-mode/src/test/java/samples/AssistantSimpleApp.java @@ -0,0 +1,84 @@ +package samples; + +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.middleware.builtin.Assistant; +import com.slack.api.bolt.socket_mode.SocketModeApp; +import com.slack.api.model.assistant.SuggestedPrompt; +import com.slack.api.model.event.AppMentionEvent; +import com.slack.api.model.event.MessageEvent; + +import java.util.Collections; + +public class AssistantSimpleApp { + + public static void main(String[] args) throws Exception { + String botToken = System.getenv("SLACK_BOT_TOKEN"); + String appToken = System.getenv("SLACK_APP_TOKEN"); + + App app = new App(AppConfig.builder().singleTeamBotToken(botToken).build()); + + Assistant assistant = new Assistant(app.executorService()); + + assistant.threadStarted((req, ctx) -> { + try { + ctx.say(r -> r.text("Hi, how can I help you today?")); + ctx.setSuggestedPrompts(Collections.singletonList( + SuggestedPrompt.create("What does SLACK stand for?") + )); + } catch (Exception e) { + ctx.logger.error("Failed to handle assistant thread started event: {e}", e); + } + }); + + assistant.userMessage((req, ctx) -> { + try { + ctx.setStatus("is typing..."); + Thread.sleep(500L); + if (ctx.getThreadContext() != null) { + String contextChannel = ctx.getThreadContext().getChannelId(); + ctx.say(r -> r.text("I am ware of the channel context: <#" + contextChannel + ">")); + } else { + ctx.say(r -> r.text("Here you are!")); + } + } catch (Exception e) { + ctx.logger.error("Failed to handle assistant user message event: {e}", e); + try { + ctx.say(r -> r.text(":warning: Sorry, something went wrong during processing your request!")); + } catch (Exception ee) { + ctx.logger.error("Failed to inform the error to the end-user: {ee}", ee); + } + } + }); + + assistant.userMessageWithFiles((req, ctx) -> { + try { + ctx.setStatus("is analyzing the files..."); + Thread.sleep(500L); + ctx.setStatus("is still checking the files..."); + Thread.sleep(500L); + ctx.say(r -> r.text("Your files do not have any issues!")); + } catch (Exception e) { + ctx.logger.error("Failed to handle assistant user message event: {e}", e); + try { + ctx.say(r -> r.text(":warning: Sorry, something went wrong during processing your request!")); + } catch (Exception ee) { + ctx.logger.error("Failed to inform the error to the end-user: {ee}", ee); + } + } + }); + + app.use(assistant); + + app.event(MessageEvent.class, (req, ctx) -> { + return ctx.ack(); + }); + + app.event(AppMentionEvent.class, (req, ctx) -> { + ctx.say("You can help you at our 1:1 DM!"); + return ctx.ack(); + }); + + new SocketModeApp(appToken, app).start(); + } +} diff --git a/bolt/src/main/java/com/slack/api/bolt/App.java b/bolt/src/main/java/com/slack/api/bolt/App.java index 3f9cc4eac..f8cab99e4 100644 --- a/bolt/src/main/java/com/slack/api/bolt/App.java +++ b/bolt/src/main/java/com/slack/api/bolt/App.java @@ -136,7 +136,10 @@ protected List buildDefaultMiddlewareList(AppConfig config) { // ignoring the events generated by this bot user if (config.isIgnoringSelfEventsEnabled()) { - middlewareList.add(new IgnoringSelfEvents(config.getSlack().getConfig())); + middlewareList.add(new IgnoringSelfEvents( + config.getSlack().getConfig(), + config.isIgnoringSelfAssistantMessageEventsEnabled() + )); } return middlewareList; @@ -307,6 +310,8 @@ public App enableTokenRevocationHandlers() { private OAuthCallbackService oAuthCallbackService; // will be initialized by initOAuthServicesIfNecessary() + private AssistantThreadContextService assistantThreadContextService; + private void initOAuthServicesIfNecessary() { if (appConfig.isDistributedApp() && appConfig.isOAuthRedirectUriPathEnabled()) { if (this.oAuthCallbackService == null) { @@ -605,6 +610,13 @@ public App use(Middleware middleware) { return this; } + public App assistant(Assistant assistant) { + if (this.assistantThreadContextService != null && assistant.getThreadContextService() == null) { + assistant.setThreadContextService(this.assistantThreadContextService); + } + return this.use(assistant); + } + // ---------------------- // App routing methods @@ -613,7 +625,7 @@ public App use(Middleware middleware) { // https://api.slack.com/events-api public App event( - Class eventClass,BoltEventHandler handler) { + Class eventClass, BoltEventHandler handler) { // Note that having multiple handlers is allowed only for message event handlers. // If we revisit this to unlock the option to all event types, it should work well. // We didn't decide to do so in 2022 in respect of better backward compatibility. @@ -936,6 +948,11 @@ public App asOpenIDConnectApp(boolean enabled) { return this; } + public App service(AssistantThreadContextService assistantThreadContextService) { + this.assistantThreadContextService = assistantThreadContextService; + return this; + } + public App service(OAuthCallbackService oAuthCallbackService) { this.oAuthCallbackService = oAuthCallbackService; putServiceInitializer(OAuthCallbackService.class, oAuthCallbackService.initializer()); diff --git a/bolt/src/main/java/com/slack/api/bolt/AppConfig.java b/bolt/src/main/java/com/slack/api/bolt/AppConfig.java index d43241539..0d858b4ce 100644 --- a/bolt/src/main/java/com/slack/api/bolt/AppConfig.java +++ b/bolt/src/main/java/com/slack/api/bolt/AppConfig.java @@ -404,4 +404,7 @@ public void setOauthRedirectUriPath(String oauthRedirectUriPath) { @Builder.Default private boolean ignoringSelfEventsEnabled = true; + @Builder.Default + private boolean ignoringSelfAssistantMessageEventsEnabled = true; + } \ No newline at end of file diff --git a/bolt/src/main/java/com/slack/api/bolt/context/builtin/EventContext.java b/bolt/src/main/java/com/slack/api/bolt/context/builtin/EventContext.java index 56ae20072..31de66edd 100644 --- a/bolt/src/main/java/com/slack/api/bolt/context/builtin/EventContext.java +++ b/bolt/src/main/java/com/slack/api/bolt/context/builtin/EventContext.java @@ -3,13 +3,21 @@ import com.slack.api.bolt.context.Context; import com.slack.api.bolt.context.FunctionUtility; import com.slack.api.bolt.context.SayUtility; +import com.slack.api.bolt.service.AssistantThreadContextService; +import com.slack.api.bolt.util.BuilderConfigurator; import com.slack.api.methods.SlackApiException; -import com.slack.api.methods.response.functions.FunctionsCompleteErrorResponse; -import com.slack.api.methods.response.functions.FunctionsCompleteSuccessResponse; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.response.asssistant.threads.AssistantThreadsSetStatusResponse; +import com.slack.api.methods.response.asssistant.threads.AssistantThreadsSetSuggestedPromptsResponse; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.model.Message; +import com.slack.api.model.assistant.AssistantThreadContext; +import com.slack.api.model.assistant.SuggestedPrompt; +import com.slack.api.model.block.LayoutBlock; import lombok.*; import java.io.IOException; -import java.util.Map; +import java.util.List; @Getter @Setter @@ -22,6 +30,99 @@ public class EventContext extends Context implements SayUtility, FunctionUtility private String channelId; + // For assistant thread events + private String threadTs; + private AssistantThreadContext threadContext; + private AssistantThreadContextService threadContextService; + private boolean assistantThreadEvent; + + private Message.Metadata buildMetadata() { + return Message.Metadata.builder() + .eventType("assistant_thread") + .eventPayload(this.getThreadContext() != null ? this.getThreadContext().toMap() : null) + .build(); + } + + @Override + public ChatPostMessageResponse say(String text) throws IOException, SlackApiException { + if (isAssistantThreadEvent()) { + return this.client().chatPostMessage(r -> r + .channel(this.getChannelId()) + .threadTs(this.getThreadTs()) + .text(text) + .metadata(this.buildMetadata()) + ); + } else { + return SayUtility.super.say(text); + } + } + + @Override + public ChatPostMessageResponse say(List blocks) throws IOException, SlackApiException { + if (isAssistantThreadEvent()) { + return this.client().chatPostMessage(r -> r + .channel(this.getChannelId()) + .threadTs(this.getThreadTs()) + .blocks(blocks) + .metadata(this.buildMetadata()) + ); + } else { + return SayUtility.super.say(blocks); + } + } + + @Override + public ChatPostMessageResponse say(String text, List blocks) throws IOException, SlackApiException { + if (isAssistantThreadEvent()) { + return this.client().chatPostMessage(r -> r + .channel(this.getChannelId()) + .threadTs(this.getThreadTs()) + .text(text) + .blocks(blocks) + .metadata(this.buildMetadata()) + ); + } else { + return SayUtility.super.say(text, blocks); + } + } + + @Override + public ChatPostMessageResponse say(BuilderConfigurator request) throws IOException, SlackApiException { + if (isAssistantThreadEvent()) { + ChatPostMessageRequest params = request.configure(ChatPostMessageRequest.builder()).build(); + params.setChannel(this.getChannelId()); + params.setThreadTs(this.getThreadTs()); + params.setMetadata(this.buildMetadata()); + return this.client().chatPostMessage(params); + } else { + return super.say(request); + } + } + + public AssistantThreadsSetStatusResponse setStatus(String status) throws IOException, SlackApiException { + if (isAssistantThreadEvent()) { + return this.client().assistantThreadsSetStatus(r -> r + .channelId(this.getChannelId()) + .threadTs(this.getThreadTs()) + .status(status) + ); + } else { + throw new IllegalStateException("This utility is only available for Assistant feature enabled app!"); + } + } + + public AssistantThreadsSetSuggestedPromptsResponse setSuggestedPrompts(List prompts) throws IOException, SlackApiException { + if (isAssistantThreadEvent()) { + return this.client().assistantThreadsSetSuggestedPrompts(r -> r + .channelId(this.getChannelId()) + .threadTs(this.getThreadTs()) + .prompts(prompts) + ); + } else { + throw new IllegalStateException("This utility is only available for Assistant feature enabled app!"); + } + } + // X-Slack-Retry-Num: 2 in HTTP Mode // "retry_attempt": 0, in Socket Mode private Integer retryNum; diff --git a/bolt/src/main/java/com/slack/api/bolt/handler/AssistantEventHandler.java b/bolt/src/main/java/com/slack/api/bolt/handler/AssistantEventHandler.java new file mode 100644 index 000000000..180725b8e --- /dev/null +++ b/bolt/src/main/java/com/slack/api/bolt/handler/AssistantEventHandler.java @@ -0,0 +1,21 @@ +package com.slack.api.bolt.handler; + +import com.slack.api.app_backend.events.payload.EventsApiPayload; +import com.slack.api.bolt.context.builtin.EventContext; +import com.slack.api.bolt.response.Response; +import com.slack.api.methods.SlackApiException; +import com.slack.api.model.event.Event; + +import java.io.IOException; + +/** + * A handler for Assistant Events API. + * + * @param + */ +@FunctionalInterface +public interface AssistantEventHandler { + + void apply(EventsApiPayload event, EventContext context); + +} diff --git a/bolt/src/main/java/com/slack/api/bolt/middleware/builtin/Assistant.java b/bolt/src/main/java/com/slack/api/bolt/middleware/builtin/Assistant.java new file mode 100644 index 000000000..3d5244e72 --- /dev/null +++ b/bolt/src/main/java/com/slack/api/bolt/middleware/builtin/Assistant.java @@ -0,0 +1,224 @@ +package com.slack.api.bolt.middleware.builtin; + +import com.slack.api.app_backend.events.payload.EventsApiPayload; +import com.slack.api.bolt.context.builtin.EventContext; +import com.slack.api.bolt.handler.AssistantEventHandler; +import com.slack.api.bolt.middleware.Middleware; +import com.slack.api.bolt.middleware.MiddlewareChain; +import com.slack.api.bolt.request.Request; +import com.slack.api.bolt.request.RequestType; +import com.slack.api.bolt.request.builtin.EventRequest; +import com.slack.api.bolt.response.Response; +import com.slack.api.bolt.service.AssistantThreadContextService; +import com.slack.api.bolt.service.builtin.DefaultAssistantThreadContextService; +import com.slack.api.model.assistant.AssistantThreadContext; +import com.slack.api.model.event.*; +import com.slack.api.util.thread.DaemonThreadExecutorServiceProvider; +import lombok.Getter; +import lombok.Setter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; +import java.util.concurrent.ExecutorService; + +import static com.slack.api.bolt.util.EventsApiPayloadParser.buildEventPayload; + +public class Assistant implements Middleware { + + @Getter + @Setter + private AssistantThreadContextService threadContextService; + + @Getter + @Setter + private boolean threadContextAutoSave; + + @Getter + @Setter + private ExecutorService executorService; + + @Getter + @Setter + private Logger logger = LoggerFactory.getLogger(Assistant.class); + + private AssistantEventHandler threadStarted; + private AssistantEventHandler threadContextChanged; + private AssistantEventHandler userMessage; + private AssistantEventHandler userMessageWithFiles; + private AssistantEventHandler botMessage; + + public Assistant() { + this(null, buildDefaultExecutorService(), buildDefaultLogger()); + } + + public Assistant(ExecutorService executorService) { + this(null, executorService, buildDefaultLogger()); + } + + public Assistant(ExecutorService executorService, Logger logger) { + this(null, executorService, logger); + } + + public Assistant(AssistantThreadContextService threadContextService) { + this(threadContextService, buildDefaultExecutorService(), buildDefaultLogger()); + } + + public Assistant(AssistantThreadContextService threadContextService, ExecutorService executorService) { + this(threadContextService, executorService, buildDefaultLogger()); + } + + public Assistant(AssistantThreadContextService threadContextService, ExecutorService executorService, Logger logger) { + setThreadContextAutoSave(true); + setThreadContextService(threadContextService); + setExecutorService(executorService); + setLogger(logger); + } + + @Override + public Response apply(Request req, Response resp, MiddlewareChain chain) throws Exception { + if (req.getRequestType().equals(RequestType.Event) && ((EventContext) req.getContext()).isAssistantThreadEvent()) { + // Handle only assistant thread events + EventRequest request = (EventRequest) req; + EventContext context = request.getContext(); + + if (getThreadContextService() == null) { + setThreadContextService(new DefaultAssistantThreadContextService(request.getContext())); + } + context.setThreadContextService(this.getThreadContextService()); + + if (isThreadContextAutoSave() && context.getThreadContext() != null) { + this.getThreadContextService().saveCurrentContext(context.getChannelId(), context.getThreadTs(), context.getThreadContext()); + } + switch (request.getEventType()) { + case AssistantThreadStartedEvent.TYPE_NAME: + if (this.threadStarted == null) { + // the default implementation automatically save the new context and does not do anything else + getExecutorService().submit(() -> { + try { + context.say(r -> r.text("Hi, how can I help you today?")); + } catch (Exception e) { + getLogger().error("Failed to send the first response: {e}", e); + } + }); + } else { + getExecutorService().submit(() -> { + try { + this.threadStarted.apply(buildEventPayload(request), context); + } catch (Exception e) { + getLogger().error("Failed to run threadStarted handler: {e}", e); + } + }); + } + return context.ack(); + case AssistantThreadContextChangedEvent.TYPE_NAME: + if (this.threadContextChanged == null) { + // the default implementation automatically save the new context and does not do anything else + getExecutorService().submit(() -> { + try { + this.getThreadContextService().saveCurrentContext( + context.getChannelId(), + context.getThreadTs(), + context.getThreadContext() + ); + } catch (Exception e) { + getLogger().error("Failed to save new thread context: {e}", e); + } + }); + } else { + getExecutorService().submit(() -> { + try { + this.threadContextChanged.apply(buildEventPayload(request), context); + } catch (Exception e) { + getLogger().error("Failed to run threadContextChanged handler: {e}", e); + } + }); + } + return context.ack(); + case MessageEvent.TYPE_NAME: + String[] elements = request.getEventTypeAndSubtype().split(":"); + if (elements.length == 1) { + loadCurrentThreadContext(context); + getExecutorService().submit(() -> { + EventsApiPayload payload = buildEventPayload(request); + if (payload.getEvent().getBotId() != null) { + try { + this.botMessage.apply(payload, context); + } catch (Exception e) { + getLogger().error("Failed to run botMessage handler: {e}", e); + } + } else { + try { + this.userMessage.apply(payload, context); + } catch (Exception e) { + getLogger().error("Failed to run userMessage handler: {e}", e); + } + } + }); + return context.ack(); + } else if (elements.length == 2 && elements[1].equals(MessageFileShareEvent.SUBTYPE_NAME)) { + loadCurrentThreadContext(context); + getExecutorService().submit(() -> { + try { + this.userMessageWithFiles.apply(buildEventPayload(request), request.getContext()); + } catch (Exception e) { + getLogger().error("Failed to run userMessageWithFiles handler: {e}", e); + } + }); + return context.ack(); + } else { + // message_changed etc. + return context.ack(); + } + default: + // noop + } + } + return chain.next(req); + } + + public Assistant threadStarted(AssistantEventHandler handler) { + this.threadStarted = handler; + return this; + } + + public Assistant threadContextChanged(AssistantEventHandler handler) { + this.threadContextChanged = handler; + return this; + } + + public Assistant userMessage(AssistantEventHandler handler) { + this.userMessage = handler; + return this; + } + + public Assistant userMessageWithFiles(AssistantEventHandler handler) { + this.userMessageWithFiles = handler; + return this; + } + + public Assistant botMessage(AssistantEventHandler handler) { + this.botMessage = handler; + return this; + } + + // ------------------------------------------------------------------- + // Private methods + // ------------------------------------------------------------------- + + private void loadCurrentThreadContext(EventContext context) { + Optional threadContext = getThreadContextService().findCurrentContext(context.getChannelId(), context.getThreadTs()); + if (threadContext != null && threadContext.isPresent()) { + context.setThreadContext(threadContext.get()); + } + } + + private static ExecutorService buildDefaultExecutorService() { + return DaemonThreadExecutorServiceProvider.getInstance() + .createThreadPoolExecutor("bolt-assistant-app-threads", 10); + } + + private static Logger buildDefaultLogger() { + return LoggerFactory.getLogger(Assistant.class); + } +} diff --git a/bolt/src/main/java/com/slack/api/bolt/middleware/builtin/IgnoringSelfEvents.java b/bolt/src/main/java/com/slack/api/bolt/middleware/builtin/IgnoringSelfEvents.java index 8555b608c..ed0d91fef 100644 --- a/bolt/src/main/java/com/slack/api/bolt/middleware/builtin/IgnoringSelfEvents.java +++ b/bolt/src/main/java/com/slack/api/bolt/middleware/builtin/IgnoringSelfEvents.java @@ -10,6 +10,7 @@ import com.slack.api.bolt.request.RequestType; import com.slack.api.bolt.request.builtin.EventRequest; import com.slack.api.bolt.response.Response; +import com.slack.api.bolt.util.EventsApiPayloadParser; import com.slack.api.methods.MethodsClient; import com.slack.api.methods.SlackApiException; import com.slack.api.methods.response.bots.BotsInfoResponse; @@ -31,9 +32,15 @@ public class IgnoringSelfEvents implements Middleware { private final Gson gson; + private final boolean ignoringSelfAssistantMessageEventsEnabled; public IgnoringSelfEvents(SlackConfig config) { + this(config, false); + } + + public IgnoringSelfEvents(SlackConfig config, boolean ignoringSelfAssistantMessageEventsEnabled) { this.gson = GsonFactory.createSnakeCase(config); + this.ignoringSelfAssistantMessageEventsEnabled = ignoringSelfAssistantMessageEventsEnabled; } // cached bot_id <> bot_user_id mapping @@ -89,6 +96,10 @@ public Response apply(Request req, Response resp, MiddlewareChain chain) throws eventBotUserId = findAndSaveBotUserId(req.getContext().client(), botId); } if (eventBotUserId != null && eventBotUserId.equals(appBotUserId)) { + if (!this.ignoringSelfAssistantMessageEventsEnabled && EventsApiPayloadParser.isMessageEventInAssistantThread(eventElem)) { + // Let Assistant#botMessage handle this pattern + return chain.next(req); + } log.debug("Skipped the event (type: {}) as it was generated by this app's bot user", eventType); return resp; } diff --git a/bolt/src/main/java/com/slack/api/bolt/request/builtin/EventRequest.java b/bolt/src/main/java/com/slack/api/bolt/request/builtin/EventRequest.java index 58112e11b..85688a7a9 100644 --- a/bolt/src/main/java/com/slack/api/bolt/request/builtin/EventRequest.java +++ b/bolt/src/main/java/com/slack/api/bolt/request/builtin/EventRequest.java @@ -7,8 +7,10 @@ import com.slack.api.bolt.request.Request; import com.slack.api.bolt.request.RequestHeaders; import com.slack.api.bolt.request.RequestType; -import com.slack.api.model.event.MessageEvent; +import com.slack.api.bolt.util.EventsApiPayloadParser; +import com.slack.api.model.assistant.AssistantThreadContext; import com.slack.api.model.event.FunctionExecutedEvent; +import com.slack.api.model.event.MessageEvent; import com.slack.api.util.json.GsonFactory; import lombok.ToString; @@ -108,6 +110,38 @@ public EventRequest( } else if (event.get("channel_id") != null) { this.getContext().setChannelId(event.get("channel_id").getAsString()); } + // assistant thread events + if (event.get("assistant_thread") != null) { + this.getContext().setAssistantThreadEvent(true); + // assistant_thread_started, assistant_thread_context_changed events + JsonObject thread = event.get("assistant_thread").getAsJsonObject(); + String channelId = thread.get("channel_id").getAsString(); + this.getContext().setChannelId(channelId); + String threadTs = thread.get("thread_ts").getAsString(); + this.getContext().setThreadTs(threadTs); + JsonObject context = thread.get("context").getAsJsonObject(); + if (context != null) { + AssistantThreadContext threadContext = AssistantThreadContext.builder() + .enterpriseId(context.get("enterprise_id") != null ? context.get("enterprise_id").getAsString() : null) + .teamId(context.get("team_id") != null ? context.get("team_id").getAsString() : null) + .channelId(context.get("channel_id") != null ? context.get("channel_id").getAsString() : null) + .build(); + this.getContext().setThreadContext(threadContext); + } + } else if (this.eventType != null + && this.eventType.equals(MessageEvent.TYPE_NAME) + && EventsApiPayloadParser.isMessageEventInAssistantThread(event)) { + // message events (user replies) + this.getContext().setAssistantThreadEvent(true); + this.getContext().setChannelId(event.get("channel").getAsString()); + if (event.get("thread_ts") != null) { + this.getContext().setThreadTs(event.get("thread_ts").getAsString()); + } else if (event.get("message") != null && event.get("message").getAsJsonObject().get("thread_ts") != null) { + // message_changed + this.getContext().setThreadTs(event.get("message").getAsJsonObject().get("thread_ts").getAsString()); + } + // Assistant middleware can set threadContext using AssistantThreadContextStore + } if (this.eventType != null && this.eventType.equals(FunctionExecutedEvent.TYPE_NAME)) { if (event.get("bot_access_token") != null) { @@ -149,6 +183,26 @@ private static String extractRequestUserId(JsonObject payload) { return null; } + private boolean isMessageEventInAssistantThread(JsonObject event) { + if (event.get("channel_type") != null && event.get("channel_type").getAsString().equals("im")) { + if (event.get("thread_ts") != null) return true; + if (event.get("message") != null) { + // message_changed + return isAssistantThreadMessageSubEvent(event, "message"); + } else if (event.get("previous_message") != null) { + // message_deleted + return isAssistantThreadMessageSubEvent(event, "previous_message"); + } + } + return false; + } + + private boolean isAssistantThreadMessageSubEvent(JsonObject event, String message) { + JsonElement subtype = event.get(message).getAsJsonObject().get("subtype"); + return (subtype != null && subtype.getAsString().equals("assistant_app_thread")) + || event.get(message).getAsJsonObject().get("thread_ts") != null; + } + private final EventContext context = new EventContext(); @Override @@ -191,4 +245,4 @@ public String getEventTypeAndSubtype() { public String getResponseUrl() { return null; } -} +} \ No newline at end of file diff --git a/bolt/src/main/java/com/slack/api/bolt/service/AssistantThreadContextService.java b/bolt/src/main/java/com/slack/api/bolt/service/AssistantThreadContextService.java new file mode 100644 index 000000000..17e4518db --- /dev/null +++ b/bolt/src/main/java/com/slack/api/bolt/service/AssistantThreadContextService.java @@ -0,0 +1,13 @@ +package com.slack.api.bolt.service; + +import com.slack.api.model.assistant.AssistantThreadContext; + +import java.util.Optional; + +public interface AssistantThreadContextService { + + Optional findCurrentContext(String channelId, String threadTs); + + void saveCurrentContext(String channelId, String threadTs, AssistantThreadContext context); + +} diff --git a/bolt/src/main/java/com/slack/api/bolt/service/builtin/DefaultAssistantThreadContextService.java b/bolt/src/main/java/com/slack/api/bolt/service/builtin/DefaultAssistantThreadContextService.java new file mode 100644 index 000000000..551c90fcc --- /dev/null +++ b/bolt/src/main/java/com/slack/api/bolt/service/builtin/DefaultAssistantThreadContextService.java @@ -0,0 +1,86 @@ +package com.slack.api.bolt.service.builtin; + +import com.slack.api.bolt.context.Context; +import com.slack.api.bolt.service.AssistantThreadContextService; +import com.slack.api.methods.MethodsClient; +import com.slack.api.model.Message; +import com.slack.api.model.assistant.AssistantThreadContext; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class DefaultAssistantThreadContextService implements AssistantThreadContextService { + private Context context; + private MethodsClient client; + + public DefaultAssistantThreadContextService(Context context) { + this.context = context; + this.client = context.client(); + } + + @Override + public Optional findCurrentContext(String channelId, String threadTs) { + Optional firstReplyMessage = this.findFirstReplyMessage(channelId, threadTs); + if (firstReplyMessage.isPresent() && firstReplyMessage.get().getMetadata() != null) { + Map context = firstReplyMessage.get().getMetadata().getEventPayload(); + if (context != null && !context.isEmpty()) { + return Optional.of(AssistantThreadContext.builder() + .enterpriseId(context.get("enterpriseId") != null ? context.get("enterpriseId").toString() : null) + .teamId(context.get("teamId") != null ? context.get("teamId").toString() : null) + .channelId(context.get("channelId") != null ? context.get("channelId").toString() : null) + .build()); + } + } + return Optional.empty(); + } + + @Override + public void saveCurrentContext(String channelId, String threadTs, AssistantThreadContext context) { + Optional firstReplyMessage = this.findFirstReplyMessage(channelId, threadTs); + if (firstReplyMessage.isPresent()) { + Message message = firstReplyMessage.get(); + try { + Map payload = new HashMap<>(); + payload.put("enterpriseId", context.getEnterpriseId()); + payload.put("teamId", context.getTeamId()); + payload.put("channelId", context.getChannelId()); + + this.client.chatUpdate(r -> r + .channel(channelId) + .ts(message.getTs()) + .text(message.getText()) + .metadata(Message.Metadata.builder() + .eventType("assistant_thread") + .eventPayload(payload) + .build()) + ); + } catch (Exception e) { + this.context.logger.error("Failed to update the first reply: {e}", e); + } + } + } + + private Optional findFirstReplyMessage(String channelId, String threadTs) { + try { + List messages = this.client.conversationsReplies(r -> r + .channel(channelId) + .ts(threadTs) + .oldest(threadTs) + .includeAllMetadata(true) + .limit(4) + ).getMessages(); + if (messages != null) { + for (Message message : messages) { + if (message.getSubtype() == null && message.getUser().equals(this.context.getBotUserId())) { + return Optional.of(message); + } + } + } + return Optional.empty(); + } catch (Exception e) { + return Optional.empty(); + } + } +} diff --git a/bolt/src/main/java/com/slack/api/bolt/util/EventsApiPayloadParser.java b/bolt/src/main/java/com/slack/api/bolt/util/EventsApiPayloadParser.java index ae417d089..440a066ad 100644 --- a/bolt/src/main/java/com/slack/api/bolt/util/EventsApiPayloadParser.java +++ b/bolt/src/main/java/com/slack/api/bolt/util/EventsApiPayloadParser.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.slack.api.app_backend.events.payload.Authorization; import com.slack.api.app_backend.events.payload.EventsApiPayload; import com.slack.api.bolt.request.builtin.EventRequest; @@ -70,6 +71,26 @@ public static final Class getEventClass(String eventType) { return null; } + public static boolean isMessageEventInAssistantThread(JsonObject event) { + if (event.get("channel_type") != null && event.get("channel_type").getAsString().equals("im")) { + if (event.get("thread_ts") != null) return true; + if (event.get("message") != null) { + // message_changed + return isAssistantThreadMessageSubEvent(event, "message"); + } else if (event.get("previous_message") != null) { + // message_deleted + return isAssistantThreadMessageSubEvent(event, "previous_message"); + } + } + return false; + } + + public static boolean isAssistantThreadMessageSubEvent(JsonObject event, String message) { + JsonElement subtype = event.get(message).getAsJsonObject().get("subtype"); + return (subtype != null && subtype.getAsString().equals("assistant_app_thread")) + || event.get(message).getAsJsonObject().get("thread_ts") != null; + } + @Data private static class BoltEventPayload implements EventsApiPayload { private String token; diff --git a/bolt/src/test/java/test_locally/app/EventAssistantTest.java b/bolt/src/test/java/test_locally/app/EventAssistantTest.java new file mode 100644 index 000000000..bbe851f25 --- /dev/null +++ b/bolt/src/test/java/test_locally/app/EventAssistantTest.java @@ -0,0 +1,230 @@ +package test_locally.app; + +import com.slack.api.Slack; +import com.slack.api.SlackConfig; +import com.slack.api.app_backend.SlackSignature; +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.middleware.builtin.Assistant; +import com.slack.api.bolt.request.RequestHeaders; +import com.slack.api.bolt.request.builtin.EventRequest; +import com.slack.api.bolt.response.Response; +import lombok.extern.slf4j.Slf4j; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import util.AuthTestMockServer; +import util.MockSlackApiServer; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@Slf4j +public class EventAssistantTest { + + MockSlackApiServer server = new MockSlackApiServer(); + SlackConfig config = new SlackConfig(); + Slack slack = Slack.getInstance(config); + + @Before + public void setup() throws Exception { + server.start(); + config.setMethodsEndpointUrlPrefix(server.getMethodsEndpointPrefix()); + } + + @After + public void tearDown() throws Exception { + server.stop(); + } + + final String secret = "foo-bar-baz"; + final SlackSignature.Generator generator = new SlackSignature.Generator(secret); + + @Test + public void test() throws Exception { + App app = buildApp(); + AtomicBoolean threadStartedReceived = new AtomicBoolean(false); + AtomicBoolean threadContextChangedReceived = new AtomicBoolean(false); + AtomicBoolean userMessageReceived = new AtomicBoolean(false); + Assistant assistant = new Assistant(app.executorService()); + + assistant.threadStarted((req, ctx) -> { + threadStartedReceived.set(true); + }); + assistant.threadContextChanged((req, ctx) -> { + threadContextChangedReceived.set(true); + }); + assistant.userMessage((req, ctx) -> { + userMessageReceived.set(true); + }); + + app.assistant(assistant); + + String threadStarted = buildPayload("{\n" + + " \"type\": \"assistant_thread_started\",\n" + + " \"assistant_thread\": {\n" + + " \"user_id\": \"W222\",\n" + + " \"context\": {\"channel_id\": \"C222\", \"team_id\": \"T111\", \"enterprise_id\": \"E111\"},\n" + + " \"channel_id\": \"D111\",\n" + + " \"thread_ts\": \"1726133698.626339\"\n" + + " },\n" + + " \"event_ts\": \"1726133698.665188\"\n" + + "}"); + Map> rawHeaders = new HashMap<>(); + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + setRequestHeaders(threadStarted, rawHeaders, timestamp); + Response response = app.run(new EventRequest(threadStarted, new RequestHeaders(rawHeaders))); + assertEquals(200L, response.getStatusCode().longValue()); + + String threadContextChanged = buildPayload("{\n" + + " \"type\": \"assistant_thread_context_changed\",\n" + + " \"assistant_thread\": {\n" + + " \"user_id\": \"W222\",\n" + + " \"context\": {\"channel_id\": \"C333\", \"team_id\": \"T111\", \"enterprise_id\": \"E111\"},\n" + + " \"channel_id\": \"D111\",\n" + + " \"thread_ts\": \"1726133698.626339\"\n" + + " },\n" + + " \"event_ts\": \"1726133698.665188\"\n" + + "}"); + rawHeaders = new HashMap<>(); + timestamp = String.valueOf(System.currentTimeMillis() / 1000); + setRequestHeaders(threadContextChanged, rawHeaders, timestamp); + response = app.run(new EventRequest(threadContextChanged, new RequestHeaders(rawHeaders))); + assertEquals(200L, response.getStatusCode().longValue()); + + String userMessage = buildPayload("{\n" + + " \"user\": \"W222\",\n" + + " \"type\": \"message\",\n" + + " \"ts\": \"1726133700.887259\",\n" + + " \"text\": \"When Slack was released?\",\n" + + " \"team\": \"T111\",\n" + + " \"user_team\": \"T111\",\n" + + " \"source_team\": \"T222\",\n" + + " \"user_profile\": {},\n" + + " \"thread_ts\": \"1726133698.626339\",\n" + + " \"parent_user_id\": \"W222\",\n" + + " \"channel\": \"D111\",\n" + + " \"event_ts\": \"1726133700.887259\",\n" + + " \"channel_type\": \"im\"\n" + + "}"); + rawHeaders = new HashMap<>(); + timestamp = String.valueOf(System.currentTimeMillis() / 1000); + setRequestHeaders(userMessage, rawHeaders, timestamp); + response = app.run(new EventRequest(userMessage, new RequestHeaders(rawHeaders))); + assertEquals(200L, response.getStatusCode().longValue()); + + String assistantMessageChanged = buildPayload("{\n" + + " \"type\": \"message\",\n" + + " \"subtype\": \"message_changed\",\n" + + " \"message\": {\n" + + " \"text\": \"New chat\",\n" + + " \"subtype\": \"assistant_app_thread\",\n" + + " \"user\": \"U222\",\n" + + " \"type\": \"message\",\n" + + " \"edited\": {},\n" + + " \"thread_ts\": \"1726133698.626339\",\n" + + " \"reply_count\": 2,\n" + + " \"reply_users_count\": 2,\n" + + " \"latest_reply\": \"1726133700.887259\",\n" + + " \"reply_users\": [\"U222\", \"W111\"],\n" + + " \"is_locked\": false,\n" + + " \"assistant_app_thread\": {\"title\": \"When Slack was released?\", \"title_blocks\": [], \"artifacts\": []},\n" + + " \"ts\": \"1726133698.626339\"\n" + + " },\n" + + " \"previous_message\": {\n" + + " \"text\": \"New chat\",\n" + + " \"subtype\": \"assistant_app_thread\",\n" + + " \"user\": \"U222\",\n" + + " \"type\": \"message\",\n" + + " \"edited\": {},\n" + + " \"thread_ts\": \"1726133698.626339\",\n" + + " \"reply_count\": 2,\n" + + " \"reply_users_count\": 2,\n" + + " \"latest_reply\": \"1726133700.887259\",\n" + + " \"reply_users\": [\"U222\", \"W111\"],\n" + + " \"is_locked\": false\n" + + " },\n" + + " \"channel\": \"D111\",\n" + + " \"hidden\": true,\n" + + " \"ts\": \"1726133701.028300\",\n" + + " \"event_ts\": \"1726133701.028300\",\n" + + " \"channel_type\": \"im\"\n" + + "}"); + rawHeaders = new HashMap<>(); + timestamp = String.valueOf(System.currentTimeMillis() / 1000); + setRequestHeaders(assistantMessageChanged, rawHeaders, timestamp); + response = app.run(new EventRequest(assistantMessageChanged, new RequestHeaders(rawHeaders))); + assertEquals(200L, response.getStatusCode().longValue()); + + String channelMessage = buildPayload("{\n" + + " \"user\": \"W222\",\n" + + " \"type\": \"message\",\n" + + " \"ts\": \"1726133700.887259\",\n" + + " \"text\": \"When Slack was released?\",\n" + + " \"team\": \"T111\",\n" + + " \"user_team\": \"T111\",\n" + + " \"source_team\": \"T222\",\n" + + " \"user_profile\": {},\n" + + " \"thread_ts\": \"1726133698.626339\",\n" + + " \"parent_user_id\": \"W222\",\n" + + " \"channel\": \"D111\",\n" + + " \"event_ts\": \"1726133700.887259\",\n" + + " \"channel_type\": \"channel\"\n" + + "}"); + rawHeaders = new HashMap<>(); + timestamp = String.valueOf(System.currentTimeMillis() / 1000); + setRequestHeaders(channelMessage, rawHeaders, timestamp); + response = app.run(new EventRequest(channelMessage, new RequestHeaders(rawHeaders))); + assertEquals(404L, response.getStatusCode().longValue()); + + int count = 0; + while (count < 100 && (!threadStartedReceived.get() || !threadContextChangedReceived.get() || !userMessageReceived.get())) { + Thread.sleep(10L); + count++; + } + assertTrue(threadStartedReceived.get()); + assertTrue(threadContextChangedReceived.get()); + assertTrue(userMessageReceived.get()); + } + + App buildApp() { + return new App(AppConfig.builder() + .signingSecret(secret) + .singleTeamBotToken(AuthTestMockServer.ValidToken) + .slack(slack) + .build()); + } + + String buildPayload(String event) { + return "{\n" + + " \"token\": \"verification_token\",\n" + + " \"team_id\": \"T111\",\n" + + " \"enterprise_id\": \"E111\",\n" + + " \"api_app_id\": \"A111\",\n" + + " \"event\": " + event + ",\n" + + " \"type\": \"event_callback\",\n" + + " \"event_id\": \"Ev111\",\n" + + " \"event_time\": 1599616881,\n" + + " \"authorizations\": [\n" + + " {\n" + + " \"enterprise_id\": \"E111\",\n" + + " \"team_id\": \"T111\",\n" + + " \"user_id\": \"W111\",\n" + + " \"is_bot\": true,\n" + + " \"is_enterprise_install\": false\n" + + " }\n" + + " ]\n" + + "}"; + } + + void setRequestHeaders(String requestBody, Map> rawHeaders, String timestamp) { + rawHeaders.put(SlackSignature.HeaderNames.X_SLACK_REQUEST_TIMESTAMP, Collections.singletonList(timestamp)); + rawHeaders.put(SlackSignature.HeaderNames.X_SLACK_SIGNATURE, Collections.singletonList(generator.generate(timestamp, requestBody))); + } +} diff --git a/codecov.yml b/codecov.yml index e4487e37a..ebf1d5704 100644 --- a/codecov.yml +++ b/codecov.yml @@ -5,5 +5,5 @@ coverage: threshold: 1.5% patch: default: - target: 50% + target: 30% diff --git a/json-logs/samples/events/MessagePayload.json b/json-logs/samples/events/MessagePayload.json index cb953a0d2..1cd6dca36 100644 --- a/json-logs/samples/events/MessagePayload.json +++ b/json-logs/samples/events/MessagePayload.json @@ -815,6 +815,9 @@ "is_file_attachment": false } ], + "metadata": { + "event_type": "" + }, "ts": "", "parent_user_id": "", "thread_ts": "", diff --git a/metadata/web-api/rate_limit_tiers.json b/metadata/web-api/rate_limit_tiers.json index 390329174..6c53581a8 100644 --- a/metadata/web-api/rate_limit_tiers.json +++ b/metadata/web-api/rate_limit_tiers.json @@ -112,7 +112,7 @@ "apps.permissions.users.list": "Tier2", "apps.permissions.users.request": "Tier2", "apps.uninstall": "Tier1", - "assistant.threads.setStatus": "Tier3", + "assistant.threads.setStatus": "SpecialTier_assistant_threads_setStatus", "assistant.threads.setSuggestedPrompts": "Tier3", "assistant.threads.setTitle": "Tier3", "auth.revoke": "Tier3", diff --git a/slack-api-client/src/main/java/com/slack/api/audit/impl/AsyncAuditRateLimiter.java b/slack-api-client/src/main/java/com/slack/api/audit/impl/AsyncAuditRateLimiter.java index ee2cca8c7..677a70225 100644 --- a/slack-api-client/src/main/java/com/slack/api/audit/impl/AsyncAuditRateLimiter.java +++ b/slack-api-client/src/main/java/com/slack/api/audit/impl/AsyncAuditRateLimiter.java @@ -74,7 +74,12 @@ public WaitTime acquireWaitTime(String teamId, String methodName) { @Override public WaitTime acquireWaitTimeForChatPostMessage(String teamId, String channel) { - return waitTimeCalculator.calculateWaitTimeForChatPostMessage(teamId, channel); + throw new IllegalStateException("This rate limiter does not handle the pattern"); + } + + @Override + public WaitTime acquireWaitTimeForAssistantThreadsSetStatus(String teamId, String channel) { + throw new IllegalStateException("This rate limiter does not handle the pattern"); } } diff --git a/slack-api-client/src/main/java/com/slack/api/methods/MethodsCustomRateLimitResolver.java b/slack-api-client/src/main/java/com/slack/api/methods/MethodsCustomRateLimitResolver.java index 6482aa367..64d17cbeb 100644 --- a/slack-api-client/src/main/java/com/slack/api/methods/MethodsCustomRateLimitResolver.java +++ b/slack-api-client/src/main/java/com/slack/api/methods/MethodsCustomRateLimitResolver.java @@ -31,6 +31,16 @@ public interface MethodsCustomRateLimitResolver { */ Optional getCustomAllowedRequestsForChatPostMessagePerMinute(String teamId, String channel); + /** + * Return a present value only when you want to override the allowed requests per minute. + * Otherwise, returning Optional.empty() will result in falling back to the built-in calculation. + * + * @param teamId the workspace ID + * @param channel either a channel ID or channel name + * @return the number of allowed requests per minute + */ + Optional getCustomAllowedRequestsForAssistantThreadsSetStatusPerMinute(String teamId, String channel); + class Default implements MethodsCustomRateLimitResolver { @Override public Optional getCustomAllowedRequestsPerMinute(String teamId, String methodName) { @@ -41,6 +51,11 @@ public Optional getCustomAllowedRequestsPerMinute(String teamId, String public Optional getCustomAllowedRequestsForChatPostMessagePerMinute(String teamId, String channel) { return Optional.empty(); } + + @Override + public Optional getCustomAllowedRequestsForAssistantThreadsSetStatusPerMinute(String teamId, String channel) { + return Optional.empty(); + } } MethodsCustomRateLimitResolver DEFAULT = new Default(); diff --git a/slack-api-client/src/main/java/com/slack/api/methods/MethodsRateLimitTier.java b/slack-api-client/src/main/java/com/slack/api/methods/MethodsRateLimitTier.java index b4c173fd6..a3cc25645 100644 --- a/slack-api-client/src/main/java/com/slack/api/methods/MethodsRateLimitTier.java +++ b/slack-api-client/src/main/java/com/slack/api/methods/MethodsRateLimitTier.java @@ -41,6 +41,11 @@ public enum MethodsRateLimitTier { */ SpecialTier_auth_test, + /** + * assistant.threads.setStatus has the similar tier with chat.postMessage API. + */ + SpecialTier_assistant_threads_setStatus, + /** * chat.postMessage has special rate limiting conditions. * It will generally allow an app to post 1 message per second to a specific channel. @@ -66,6 +71,7 @@ public enum MethodsRateLimitTier { allowedRequestsPerMinute.put(MethodsRateLimitTier.SpecialTier_auth_test, 600); allowedRequestsPerMinute.put(MethodsRateLimitTier.SpecialTier_chat_getPermalink, 600); allowedRequestsPerMinute.put(MethodsRateLimitTier.SpecialTier_chat_postMessage, 60); // per channel + allowedRequestsPerMinute.put(MethodsRateLimitTier.SpecialTier_assistant_threads_setStatus, 60); // per DM } public static Integer getAllowedRequestsPerMinute(MethodsRateLimitTier tier) { diff --git a/slack-api-client/src/main/java/com/slack/api/methods/MethodsRateLimits.java b/slack-api-client/src/main/java/com/slack/api/methods/MethodsRateLimits.java index cfd55296f..e08e49e2d 100644 --- a/slack-api-client/src/main/java/com/slack/api/methods/MethodsRateLimits.java +++ b/slack-api-client/src/main/java/com/slack/api/methods/MethodsRateLimits.java @@ -227,7 +227,7 @@ public static void setRateLimitTier(String methodName, MethodsRateLimitTier tier setRateLimitTier(APPS_UNINSTALL, Tier1); setRateLimitTier(APPS_EVENT_AUTHORIZATIONS_LIST, Tier4); - setRateLimitTier(ASSISTANT_THREADS_SET_STATUS, Tier3); + setRateLimitTier(ASSISTANT_THREADS_SET_STATUS, SpecialTier_assistant_threads_setStatus); setRateLimitTier(ASSISTANT_THREADS_SET_SUGGESTED_PROMPTS, Tier3); setRateLimitTier(ASSISTANT_THREADS_SET_TITLE, Tier3); diff --git a/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncMethodsClientImpl.java b/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncMethodsClientImpl.java index 14662ce6c..b15432db5 100644 --- a/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncMethodsClientImpl.java +++ b/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncMethodsClientImpl.java @@ -1363,7 +1363,10 @@ public CompletableFuture appsManifestValidate(Requ @Override public CompletableFuture assistantThreadsSetStatus(AssistantThreadsSetStatusRequest req) { - return executor.execute(ASSISTANT_THREADS_SET_STATUS, toMap(req), () -> methods.assistantThreadsSetStatus(req)); + Map params = new HashMap<>(); + params.put("token", token(req)); + params.put("channel_id", req.getChannelId()); // for rate limiting + return executor.execute(ASSISTANT_THREADS_SET_STATUS, params, () -> methods.assistantThreadsSetStatus(req)); } @Override diff --git a/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncMethodsRateLimiter.java b/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncMethodsRateLimiter.java index 5f44f4643..54b9664ff 100644 --- a/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncMethodsRateLimiter.java +++ b/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncMethodsRateLimiter.java @@ -13,6 +13,7 @@ import java.util.Optional; +import static com.slack.api.methods.MethodsRateLimitTier.SpecialTier_assistant_threads_setStatus; import static com.slack.api.methods.MethodsRateLimitTier.SpecialTier_chat_postMessage; @Slf4j @@ -82,6 +83,31 @@ public WaitTime acquireWaitTimeForChatPostMessage(String teamId, String channel) ); } + public int getAllowedRequestsForAssistantThreadsSetStatusPerMinute(String teamId, String channel) { + Optional custom = customRateLimitResolver.getCustomAllowedRequestsForAssistantThreadsSetStatusPerMinute(teamId, channel); + if (custom.isPresent()) { + return custom.get(); + } + return waitTimeCalculator.getAllowedRequestsPerMinute(SpecialTier_assistant_threads_setStatus); + } + + @Override + public WaitTime acquireWaitTimeForAssistantThreadsSetStatus(String teamId, String channel) { + // See MethodsClientImpl#buildMethodNameAndSuffix() for the consistency of this logic + String methodName = Methods.ASSISTANT_THREADS_SET_STATUS + "_" + channel; + Optional rateLimitedEpochMillis = waitTimeCalculator + .getRateLimitedMethodRetryEpochMillis(executorName, teamId, methodName); + if (rateLimitedEpochMillis.isPresent()) { + long millisToWait = rateLimitedEpochMillis.get() - System.currentTimeMillis(); + return new WaitTime(millisToWait, RequestPace.RateLimited); + } + return waitTimeCalculator.calculateWaitTimeForAssistantThreadsSetStatus( + teamId, + channel, + getAllowedRequestsForAssistantThreadsSetStatusPerMinute(teamId, channel) + ); + } + public static class MethodsWaitTimeCalculator extends WaitTimeCalculator { private final MethodsConfig config; diff --git a/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncRateLimitExecutor.java b/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncRateLimitExecutor.java index 90b87cf84..98ccd8917 100644 --- a/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncRateLimitExecutor.java +++ b/slack-api-client/src/main/java/com/slack/api/methods/impl/AsyncRateLimitExecutor.java @@ -112,6 +112,9 @@ private String toMethodNameWithSuffix(String methodName, Map par if (methodName.equals(Methods.CHAT_POST_MESSAGE)) { methodNameWithSuffix = Methods.CHAT_POST_MESSAGE + "_" + params.get("channel"); } + if (methodName.equals(Methods.ASSISTANT_THREADS_SET_STATUS)) { + methodNameWithSuffix = Methods.ASSISTANT_THREADS_SET_STATUS + "_" + params.get("channel_id"); + } return methodNameWithSuffix; } diff --git a/slack-api-client/src/main/java/com/slack/api/rate_limits/RateLimiter.java b/slack-api-client/src/main/java/com/slack/api/rate_limits/RateLimiter.java index 3efd383f9..fbb555055 100644 --- a/slack-api-client/src/main/java/com/slack/api/rate_limits/RateLimiter.java +++ b/slack-api-client/src/main/java/com/slack/api/rate_limits/RateLimiter.java @@ -6,6 +6,8 @@ public interface RateLimiter { WaitTime acquireWaitTimeForChatPostMessage(String teamId, String channel); + WaitTime acquireWaitTimeForAssistantThreadsSetStatus(String teamId, String channel); + long DEFAULT_BACKGROUND_JOB_INTERVAL_MILLIS = 1_000L; } diff --git a/slack-api-client/src/main/java/com/slack/api/rate_limits/WaitTimeCalculator.java b/slack-api-client/src/main/java/com/slack/api/rate_limits/WaitTimeCalculator.java index 3e0e4df11..f8b6edd11 100644 --- a/slack-api-client/src/main/java/com/slack/api/rate_limits/WaitTimeCalculator.java +++ b/slack-api-client/src/main/java/com/slack/api/rate_limits/WaitTimeCalculator.java @@ -60,6 +60,14 @@ public WaitTime calculateWaitTimeForChatPostMessage(String teamId, String channe ); } + public WaitTime calculateWaitTimeForAssistantThreadsSetStatus(String teamId, String channel, int allowedRequests) { + return calculateWaitTime( + teamId, + Methods.ASSISTANT_THREADS_SET_STATUS + "_" + channel, + allowedRequests + ); + } + private boolean isBurst(LastMinuteRequests lastMinuteRequests, int allowedRequests) { if (lastMinuteRequests.size() > (allowedRequests / 10)) { long threeSecondsAgo = System.currentTimeMillis() - 3000L; diff --git a/slack-api-client/src/main/java/com/slack/api/scim/impl/AsyncSCIMRateLimiter.java b/slack-api-client/src/main/java/com/slack/api/scim/impl/AsyncSCIMRateLimiter.java index d2935f6ae..cd1c2f624 100644 --- a/slack-api-client/src/main/java/com/slack/api/scim/impl/AsyncSCIMRateLimiter.java +++ b/slack-api-client/src/main/java/com/slack/api/scim/impl/AsyncSCIMRateLimiter.java @@ -143,7 +143,12 @@ public WaitTime acquireWaitTime(String teamId, String methodName) { @Override public WaitTime acquireWaitTimeForChatPostMessage(String teamId, String channel) { - return waitTimeCalculator.calculateWaitTimeForChatPostMessage(teamId, channel); + throw new IllegalStateException("This rate limiter does not handle the pattern"); + } + + @Override + public WaitTime acquireWaitTimeForAssistantThreadsSetStatus(String teamId, String channel) { + throw new IllegalStateException("This rate limiter does not handle the pattern"); } } diff --git a/slack-api-client/src/main/java/com/slack/api/scim2/impl/AsyncSCIM2RateLimiter.java b/slack-api-client/src/main/java/com/slack/api/scim2/impl/AsyncSCIM2RateLimiter.java index 6b6c69466..ea49f67e9 100644 --- a/slack-api-client/src/main/java/com/slack/api/scim2/impl/AsyncSCIM2RateLimiter.java +++ b/slack-api-client/src/main/java/com/slack/api/scim2/impl/AsyncSCIM2RateLimiter.java @@ -143,7 +143,12 @@ public WaitTime acquireWaitTime(String teamId, String methodName) { @Override public WaitTime acquireWaitTimeForChatPostMessage(String teamId, String channel) { - return waitTimeCalculator.calculateWaitTimeForChatPostMessage(teamId, channel); + throw new IllegalStateException("This rate limiter does not handle the pattern"); + } + + @Override + public WaitTime acquireWaitTimeForAssistantThreadsSetStatus(String teamId, String channel) { + throw new IllegalStateException("This rate limiter does not handle the pattern"); } } diff --git a/slack-api-client/src/test/java/test_locally/api/methods/CustomRateLimitsTest.java b/slack-api-client/src/test/java/test_locally/api/methods/CustomRateLimitsTest.java index f305e0735..31617ec1e 100644 --- a/slack-api-client/src/test/java/test_locally/api/methods/CustomRateLimitsTest.java +++ b/slack-api-client/src/test/java/test_locally/api/methods/CustomRateLimitsTest.java @@ -37,6 +37,11 @@ public Optional getCustomAllowedRequestsPerMinute(String teamId, String public Optional getCustomAllowedRequestsForChatPostMessagePerMinute(String teamId, String channel) { return Optional.empty(); } + + @Override + public Optional getCustomAllowedRequestsForAssistantThreadsSetStatusPerMinute(String teamId, String channel) { + return Optional.empty(); + } } @Before diff --git a/slack-api-client/src/test/java/test_locally/api/methods/TeamTest.java b/slack-api-client/src/test/java/test_locally/api/methods/TeamTest.java index 3f51a20a0..bfab0a352 100644 --- a/slack-api-client/src/test/java/test_locally/api/methods/TeamTest.java +++ b/slack-api-client/src/test/java/test_locally/api/methods/TeamTest.java @@ -36,6 +36,11 @@ public Optional getCustomAllowedRequestsPerMinute(String teamId, String public Optional getCustomAllowedRequestsForChatPostMessagePerMinute(String teamId, String channel) { return Optional.empty(); } + + @Override + public Optional getCustomAllowedRequestsForAssistantThreadsSetStatusPerMinute(String teamId, String channel) { + return Optional.empty(); + } }); config.setMethodsConfig(methodsConfig); } diff --git a/slack-api-client/src/test/java/test_locally/api/methods_admin_api/AdminApiAsyncTest.java b/slack-api-client/src/test/java/test_locally/api/methods_admin_api/AdminApiAsyncTest.java index d4042a0f8..d24132132 100644 --- a/slack-api-client/src/test/java/test_locally/api/methods_admin_api/AdminApiAsyncTest.java +++ b/slack-api-client/src/test/java/test_locally/api/methods_admin_api/AdminApiAsyncTest.java @@ -40,6 +40,11 @@ public Optional getCustomAllowedRequestsPerMinute(String teamId, String public Optional getCustomAllowedRequestsForChatPostMessagePerMinute(String teamId, String channel) { return Optional.empty(); } + + @Override + public Optional getCustomAllowedRequestsForAssistantThreadsSetStatusPerMinute(String teamId, String channel) { + return Optional.empty(); + } }); config.setMethodsConfig(methodsConfig); } diff --git a/slack-api-model/src/main/java/com/slack/api/model/assistant/SuggestedPrompt.java b/slack-api-model/src/main/java/com/slack/api/model/assistant/SuggestedPrompt.java index 0886d2aa0..8845cb296 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/assistant/SuggestedPrompt.java +++ b/slack-api-model/src/main/java/com/slack/api/model/assistant/SuggestedPrompt.java @@ -18,4 +18,8 @@ public SuggestedPrompt(String message) { setTitle(message); setMessage(message); } + + public static SuggestedPrompt create(String message) { + return new SuggestedPrompt(message); + } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/event/MessageEvent.java b/slack-api-model/src/main/java/com/slack/api/model/event/MessageEvent.java index 9b23fa546..24efa0370 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/event/MessageEvent.java +++ b/slack-api-model/src/main/java/com/slack/api/model/event/MessageEvent.java @@ -3,6 +3,7 @@ import com.slack.api.model.Attachment; import com.slack.api.model.BotProfile; import com.slack.api.model.File; +import com.slack.api.model.Message; import com.slack.api.model.block.LayoutBlock; import lombok.Data; @@ -37,6 +38,7 @@ public class MessageEvent implements Event { private List blocks; private List attachments; private List files; + private Message.Metadata metadata; private String ts; diff --git a/slack-api-model/src/test/java/test_locally/api/model/assistant/SuggestedPromptTest.java b/slack-api-model/src/test/java/test_locally/api/model/assistant/SuggestedPromptTest.java new file mode 100644 index 000000000..2c63e3b3f --- /dev/null +++ b/slack-api-model/src/test/java/test_locally/api/model/assistant/SuggestedPromptTest.java @@ -0,0 +1,27 @@ +package test_locally.api.model.assistant; + +import com.slack.api.model.assistant.SuggestedPrompt; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +public class SuggestedPromptTest { + @Test + public void test() { + SuggestedPrompt s1 = new SuggestedPrompt("What does SLACK stand for?"); + assertThat(s1, is(notNullValue())); + assertThat(s1.getTitle(), is("What does SLACK stand for?")); + assertThat(s1.getMessage(), is("What does SLACK stand for?")); + SuggestedPrompt s2 = new SuggestedPrompt("title", "message"); + assertThat(s2, is(notNullValue())); + assertThat(s2.getTitle(), is("title")); + assertThat(s2.getMessage(), is("message")); + SuggestedPrompt s3 = SuggestedPrompt.create("What does SLACK stand for?"); + assertThat(s3, is(notNullValue())); + assertThat(s3.getTitle(), is("What does SLACK stand for?")); + assertThat(s3.getMessage(), is("What does SLACK stand for?")); + } +} +