This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new c78c70da9652 CAMEL-25523: camel-slack - Read the following pages of
the history so the consumer does not skip messages (#27674)
c78c70da9652 is described below
commit c78c70da96521b110d5cf7d0de28660b6d6d921e
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Oct 11 12:31:58 2026 +0530
CAMEL-25523: camel-slack - Read the following pages of the history so the
consumer does not skip messages (#27674)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../org/apache/camel/catalog/components/slack.json | 2 +-
.../apache/camel/catalog/docs/slack-component.adoc | 6 +
.../org/apache/camel/component/slack/slack.json | 2 +-
.../camel-slack/src/main/docs/slack-component.adoc | 6 +
.../camel/component/slack/SlackConsumer.java | 59 ++++-
.../camel/component/slack/SlackEndpoint.java | 3 +-
.../component/slack/SlackConsumerHasMoreTest.java | 292 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 9 +
.../endpoint/dsl/SlackEndpointBuilderFactory.java | 4 +-
9 files changed, 374 insertions(+), 9 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/slack.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/slack.json
index 2acc5b4d73b0..40dcfb95686e 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/slack.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/slack.json
@@ -36,7 +36,7 @@
"channel": { "index": 0, "kind": "path", "displayName": "Channel",
"group": "common", "label": "", "required": true, "type": "string", "javaType":
"java.lang.String", "deprecated": false, "deprecationNote": "", "autowired":
false, "secret": false, "description": "The channel name (syntax #name) or
slack user (syntax userName) to send a message directly to an user." },
"token": { "index": 1, "kind": "parameter", "displayName": "Token",
"group": "common", "label": "", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "autowired": false,
"secret": true, "security": "secret", "description": "The token to access
Slack. This app needs to have channels:history, groups:history, im:history,
mpim:history, channels:read, groups:read, im:read and mpim:read permissions.
The User OAuth Token is the kind of token needed." },
"conversationType": { "index": 2, "kind": "parameter", "displayName":
"Conversation Type", "group": "consumer", "label": "consumer", "required":
false, "type": "enum", "javaType": "com.slack.api.model.ConversationType",
"enum": [ "PUBLIC_CHANNEL", "PRIVATE_CHANNEL", "MPIM", "IM" ], "deprecated":
false, "autowired": false, "secret": false, "defaultValue": "PUBLIC_CHANNEL",
"description": "Type of conversation" },
- "maxResults": { "index": 3, "kind": "parameter", "displayName": "Max
Results", "group": "consumer", "label": "consumer", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "defaultValue": "10", "description": "The Max Result
for the poll" },
+ "maxResults": { "index": 3, "kind": "parameter", "displayName": "Max
Results", "group": "consumer", "label": "consumer", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "defaultValue": "10", "description": "The maximum
number of messages that the consumer requests from the conversation history at
a time. When more new messages are available, the consumer reads the following
pages in the same poll." },
"naturalOrder": { "index": 4, "kind": "parameter", "displayName": "Natural
Order", "group": "consumer", "label": "consumer", "required": false, "type":
"boolean", "javaType": "boolean", "deprecated": false, "autowired": false,
"secret": false, "defaultValue": false, "description": "Create exchanges in
natural order (oldest to newest) or not" },
"sendEmptyMessageWhenIdle": { "index": 5, "kind": "parameter",
"displayName": "Send Empty Message When Idle", "group": "consumer", "label":
"consumer", "required": false, "type": "boolean", "javaType": "boolean",
"deprecated": false, "autowired": false, "secret": false, "defaultValue":
false, "description": "If the polling consumer did not poll any files, you can
enable this option to send an empty message (no body) instead." },
"serverUrl": { "index": 6, "kind": "parameter", "displayName": "Server
Url", "group": "consumer", "label": "consumer", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "defaultValue": "https:\/\/slack.com", "description":
"The Server URL of the Slack instance" },
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/slack-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/slack-component.adoc
index 4f7350e13eab..b0708b1d502b 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/slack-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/slack-component.adoc
@@ -188,6 +188,12 @@ YAML::
This way you'll get the last message from `general` channel.
The consumer will track the timestamp of the last message consumed,
and in the next poll it will consume only newer messages in the channel.
+When more than `maxResults` new messages were posted since the previous poll,
the consumer reads the following pages of
+the conversation history in the same poll (`maxResults` messages per request),
so no message is skipped. For a busy
+channel, set a higher `maxResults` to need fewer requests. If the request for
a following page fails, for example
+because Slack rate limits it (some apps may send only one
`conversations.history` request per minute), the consumer
+routes the most recent messages it has already read, as when it reads one page
only, and logs a WARN that the older
+messages of that poll were skipped.
You'll need to create a Slack app and use it in your workspace.
diff --git
a/components/camel-slack/src/generated/resources/META-INF/org/apache/camel/component/slack/slack.json
b/components/camel-slack/src/generated/resources/META-INF/org/apache/camel/component/slack/slack.json
index 2acc5b4d73b0..40dcfb95686e 100644
---
a/components/camel-slack/src/generated/resources/META-INF/org/apache/camel/component/slack/slack.json
+++
b/components/camel-slack/src/generated/resources/META-INF/org/apache/camel/component/slack/slack.json
@@ -36,7 +36,7 @@
"channel": { "index": 0, "kind": "path", "displayName": "Channel",
"group": "common", "label": "", "required": true, "type": "string", "javaType":
"java.lang.String", "deprecated": false, "deprecationNote": "", "autowired":
false, "secret": false, "description": "The channel name (syntax #name) or
slack user (syntax userName) to send a message directly to an user." },
"token": { "index": 1, "kind": "parameter", "displayName": "Token",
"group": "common", "label": "", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "autowired": false,
"secret": true, "security": "secret", "description": "The token to access
Slack. This app needs to have channels:history, groups:history, im:history,
mpim:history, channels:read, groups:read, im:read and mpim:read permissions.
The User OAuth Token is the kind of token needed." },
"conversationType": { "index": 2, "kind": "parameter", "displayName":
"Conversation Type", "group": "consumer", "label": "consumer", "required":
false, "type": "enum", "javaType": "com.slack.api.model.ConversationType",
"enum": [ "PUBLIC_CHANNEL", "PRIVATE_CHANNEL", "MPIM", "IM" ], "deprecated":
false, "autowired": false, "secret": false, "defaultValue": "PUBLIC_CHANNEL",
"description": "Type of conversation" },
- "maxResults": { "index": 3, "kind": "parameter", "displayName": "Max
Results", "group": "consumer", "label": "consumer", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "defaultValue": "10", "description": "The Max Result
for the poll" },
+ "maxResults": { "index": 3, "kind": "parameter", "displayName": "Max
Results", "group": "consumer", "label": "consumer", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "defaultValue": "10", "description": "The maximum
number of messages that the consumer requests from the conversation history at
a time. When more new messages are available, the consumer reads the following
pages in the same poll." },
"naturalOrder": { "index": 4, "kind": "parameter", "displayName": "Natural
Order", "group": "consumer", "label": "consumer", "required": false, "type":
"boolean", "javaType": "boolean", "deprecated": false, "autowired": false,
"secret": false, "defaultValue": false, "description": "Create exchanges in
natural order (oldest to newest) or not" },
"sendEmptyMessageWhenIdle": { "index": 5, "kind": "parameter",
"displayName": "Send Empty Message When Idle", "group": "consumer", "label":
"consumer", "required": false, "type": "boolean", "javaType": "boolean",
"deprecated": false, "autowired": false, "secret": false, "defaultValue":
false, "description": "If the polling consumer did not poll any files, you can
enable this option to send an empty message (no body) instead." },
"serverUrl": { "index": 6, "kind": "parameter", "displayName": "Server
Url", "group": "consumer", "label": "consumer", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "defaultValue": "https:\/\/slack.com", "description":
"The Server URL of the Slack instance" },
diff --git a/components/camel-slack/src/main/docs/slack-component.adoc
b/components/camel-slack/src/main/docs/slack-component.adoc
index 4f7350e13eab..b0708b1d502b 100644
--- a/components/camel-slack/src/main/docs/slack-component.adoc
+++ b/components/camel-slack/src/main/docs/slack-component.adoc
@@ -188,6 +188,12 @@ YAML::
This way you'll get the last message from `general` channel.
The consumer will track the timestamp of the last message consumed,
and in the next poll it will consume only newer messages in the channel.
+When more than `maxResults` new messages were posted since the previous poll,
the consumer reads the following pages of
+the conversation history in the same poll (`maxResults` messages per request),
so no message is skipped. For a busy
+channel, set a higher `maxResults` to need fewer requests. If the request for
a following page fails, for example
+because Slack rate limits it (some apps may send only one
`conversations.history` request per minute), the consumer
+routes the most recent messages it has already read, as when it reads one page
only, and logs a WARN that the older
+messages of that poll were skipped.
You'll need to create a Slack app and use it in your workspace.
diff --git
a/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackConsumer.java
b/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackConsumer.java
index c0d0f6fce121..216534e61dd5 100644
---
a/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackConsumer.java
+++
b/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackConsumer.java
@@ -17,6 +17,7 @@
package org.apache.camel.component.slack;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
@@ -39,11 +40,15 @@ import org.apache.camel.component.slack.helper.SlackHelper;
import org.apache.camel.support.ScheduledBatchPollingConsumer;
import org.apache.camel.util.CastUtils;
import org.apache.camel.util.ObjectHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class SlackConsumer extends ScheduledBatchPollingConsumer {
public static final long DEFAULT_CONSUMER_DELAY = 10 * 1000L;
+ private static final Logger LOG =
LoggerFactory.getLogger(SlackConsumer.class);
+
private static final int CONVERSATIONS_LIST_LIMIT = 200;
private final SlackEndpoint slackEndpoint;
private Slack slack;
@@ -77,20 +82,64 @@ public class SlackConsumer extends
ScheduledBatchPollingConsumer {
// Maximum limit is 1000. Slack recommends no more than 200 results at
a time.
// https://api.slack.com/methods/conversations.history
// We set the limit to 1 the first call to set the timestamp of the
last message of the history
+ ConversationsHistoryResponse response = conversationsHistory(null);
+
+ // okay we have some response from slack so lets mark the consumer as
ready
+ forceConsumerAsReady();
+
+ List<Message> messages = response.getMessages();
+ if (timestamp != null && response.isHasMore()) {
+ // the most recent messages of the range come first, so the
messages that did not fit into this response
+ // are older ones: read the following pages, or they are skipped
when the timestamp moves past them
+ messages = messages != null ? new ArrayList<>(messages) : new
ArrayList<>();
+ String cursor = nextCursor(response);
+ while (cursor != null) {
+ if (!isPollAllowed()) {
+ // stopping or suspended: do not keep calling Slack. The
timestamp is not moved and nothing of this
+ // poll is routed, so the first poll after a restart or
resume reads these messages again; routing
+ // the pages read so far would move the timestamp past the
older messages that were not read
+ LOG.debug("Stopped reading the conversation history of
channel {} as the consumer is stopping or"
+ + " suspended",
+ slackEndpoint.getChannel());
+ return 0;
+ }
+ try {
+ response = conversationsHistory(cursor);
+ } catch (IOException | SlackApiException |
RuntimeCamelException e) {
+ // for example rate limited: route the most recent
messages already read, as when only one page was
+ // read, instead of failing every poll
+ LOG.warn("Cannot read the following page of the
conversation history of channel {}: {}."
+ + " Routing the {} most recent new messages, the
older messages of this poll are skipped.",
+ slackEndpoint.getChannel(), e.getMessage(),
messages.size());
+ break;
+ }
+ if (response.getMessages() != null) {
+ messages.addAll(response.getMessages());
+ }
+ cursor = response.isHasMore() ? nextCursor(response) : null;
+ }
+ }
+
+ Queue<Exchange> exchanges = createExchanges(messages);
+ return processBatch(CastUtils.cast(exchanges));
+ }
+
+ private ConversationsHistoryResponse conversationsHistory(String cursor)
throws IOException, SlackApiException {
ConversationsHistoryResponse response =
slack.methods(slackEndpoint.getToken()).conversationsHistory(req -> req
.channel(channelId)
.oldest(timestamp)
+ .cursor(cursor)
.limit(timestamp != null ?
Integer.parseInt(slackEndpoint.getMaxResults()) : 1));
if (!response.isOk()) {
throw new RuntimeCamelException("API request conversations.history
to Slack failed: " + response);
}
+ return response;
+ }
- // okay we have some response from slack so lets mark the consumer as
ready
- forceConsumerAsReady();
-
- Queue<Exchange> exchanges = createExchanges(response.getMessages());
- return processBatch(CastUtils.cast(exchanges));
+ private static String nextCursor(ConversationsHistoryResponse response) {
+ String cursor = response.getResponseMetadata() != null ?
response.getResponseMetadata().getNextCursor() : null;
+ return ObjectHelper.isNotEmpty(cursor) ? cursor : null;
}
private Queue<Exchange> createExchanges(final List<Message> list) {
diff --git
a/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackEndpoint.java
b/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackEndpoint.java
index f9ff2e37d243..fb94edc48f68 100644
---
a/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackEndpoint.java
+++
b/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackEndpoint.java
@@ -182,7 +182,8 @@ public class SlackEndpoint extends ScheduledPollEndpoint {
}
/**
- * The Max Result for the poll
+ * The maximum number of messages that the consumer requests from the
conversation history at a time. When more new
+ * messages are available, the consumer reads the following pages in the
same poll.
*/
public void setMaxResults(String maxResult) {
this.maxResults = maxResult;
diff --git
a/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerHasMoreTest.java
b/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerHasMoreTest.java
new file mode 100644
index 000000000000..adbe579dcca8
--- /dev/null
+++
b/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerHasMoreTest.java
@@ -0,0 +1,292 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.slack;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+import com.slack.api.model.Message;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The consumer must not skip messages when more new messages than {@code
maxResults} were posted between two polls:
+ * conversations.history returns the most recent messages of the range first,
and the older ones on the following pages
+ * (has_more and response_metadata.next_cursor).
+ */
+class SlackConsumerHasMoreTest extends CamelTestSupport {
+
+ private final List<String> channel = new CopyOnWriteArrayList<>();
+ private HttpServer server;
+ private final AtomicInteger rateLimitedRequests = new AtomicInteger();
+ private boolean naturalOrder;
+ private volatile boolean rateLimitFollowingPages;
+ private final List<String> cursors = new CopyOnWriteArrayList<>();
+ private final AtomicReference<Runnable> beforeNextFollowingPage = new
AtomicReference<>();
+ private final CountDownLatch suspendedPollDone = new CountDownLatch(1);
+ private volatile boolean suspendedWhilePaging;
+
+ @Override
+ protected void doPreSetup() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ server.createContext("/api/conversations.list",
this::conversationsList);
+ server.createContext("/api/conversations.history",
this::conversationsHistory);
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() {
+ // stop polling before the server goes away
+ context.stop();
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ @Test
+ void consumesEveryMessageOfABurstLargerThanMaxResults() throws Exception {
+ MockEndpoint mock = startRouteAndReceiveFirstMessage();
+
+ // five messages are posted before the next poll, more than
maxResults=2
+ postBurst();
+
+ mock.expectedMessageCount(5);
+ mock.setAssertPeriod(500);
+ mock.assertIsSatisfied();
+ assertEquals(List.of("m6", "m5", "m4", "m3", "m2"), texts(mock),
+ "every new message must be routed once, the most recent
first");
+ }
+
+ @Test
+ void consumesEveryMessageOfABurstInNaturalOrder() throws Exception {
+ naturalOrder = true;
+ MockEndpoint mock = startRouteAndReceiveFirstMessage();
+
+ postBurst();
+
+ mock.expectedMessageCount(5);
+ mock.setAssertPeriod(500);
+ mock.assertIsSatisfied();
+ assertEquals(List.of("m2", "m3", "m4", "m5", "m6"), texts(mock),
+ "every new message must be routed once, the oldest first");
+ }
+
+ @Test
+ void routesTheMostRecentMessagesWhenAFollowingPageIsRateLimited() throws
Exception {
+ // apps that Slack limits to one conversations.history request per
minute get HTTP 429 for the second page
+ rateLimitFollowingPages = true;
+ MockEndpoint mock = startRouteAndReceiveFirstMessage();
+
+ postBurst();
+
+ // as when only one page is read: the most recent maxResults messages
are routed, once, and the poll does not
+ // fail (a failed poll would read the whole range again at every
following poll)
+ mock.expectedMessageCount(2);
+ mock.setAssertPeriod(500);
+ mock.assertIsSatisfied();
+ assertEquals(List.of("m6", "m5"), texts(mock));
+ assertTrue(rateLimitedRequests.get() > 0, "the following page must
have been requested");
+ }
+
+ @Test
+ void stopsPagingWhenSuspendedAndReadsTheMessagesAgainAfterResume() throws
Exception {
+ MockEndpoint mock = startRouteAndReceiveFirstMessage();
+ SlackConsumer consumer = (SlackConsumer)
context.getRoute("slack").getConsumer();
+ // the consumer is suspended while it waits for the second of the
three pages
+ beforeNextFollowingPage.set(() -> {
+ consumer.suspend();
+ suspendedWhilePaging = true;
+ });
+
+ postBurst();
+
+ assertTrue(suspendedPollDone.await(20, TimeUnit.SECONDS), "the poll
that was reading the pages did not end");
+ assertEquals(List.of("2"), cursors, "the third page must not be
requested once the consumer is suspended");
+ assertEquals(0, mock.getReceivedCounter(), "a part of the burst was
routed, so its older messages are skipped");
+
+ mock.expectedMessageCount(5);
+ mock.setAssertPeriod(500);
+ consumer.resume();
+
+ mock.assertIsSatisfied();
+ assertEquals(List.of("m6", "m5", "m4", "m3", "m2"), texts(mock),
+ "every message of the burst must be routed once after the
resume");
+ assertEquals(List.of("2", "2", "4"), cursors);
+ }
+
+ private MockEndpoint startRouteAndReceiveFirstMessage() throws Exception {
+ channel.add("m1");
+ // tells when the poll during which the route was suspended has ended
+ context.getRegistry().bind("pollEnded", new
PollingConsumerPollStrategy() {
+ @Override
+ public boolean begin(Consumer consumer, Endpoint endpoint) {
+ return true;
+ }
+
+ @Override
+ public void commit(Consumer consumer, Endpoint endpoint, int
polledMessages) {
+ if (suspendedWhilePaging) {
+ suspendedPollDone.countDown();
+ }
+ }
+
+ @Override
+ public boolean rollback(Consumer consumer, Endpoint endpoint, int
retryCounter, Exception e) {
+ return false;
+ }
+ });
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
fromF("slack://general?token=RAW(xoxp-test)&serverUrl=http://localhost:%d&maxResults=2&delay=100"
+ + "&naturalOrder=%s&pollStrategy=#pollEnded",
+ server.getAddress().getPort(), naturalOrder)
+ .routeId("slack")
+ .to("mock:result");
+ }
+ });
+ context.start();
+
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ // the first poll reads the last message of the history
+ mock.expectedMessageCount(1);
+ mock.assertIsSatisfied();
+ assertEquals(List.of("m1"), texts(mock));
+ mock.reset();
+ return mock;
+ }
+
+ private void postBurst() {
+ // added at once, so that no poll sees only a part of the burst
+ channel.addAll(List.of("m2", "m3", "m4", "m5", "m6"));
+ }
+
+ private static List<String> texts(MockEndpoint mock) {
+ List<String> answer = new ArrayList<>();
+ for (Exchange exchange : mock.getReceivedExchanges()) {
+ answer.add(exchange.getMessage().getBody(Message.class).getText());
+ }
+ return answer;
+ }
+
+ private void conversationsList(HttpExchange http) throws IOException {
+ respond(http,
"{\"ok\":true,\"channels\":[{\"id\":\"C1\",\"name\":\"general\"}],"
+ + "\"response_metadata\":{\"next_cursor\":\"\"}}");
+ }
+
+ /**
+ * Answers as Slack does: the messages after {@code oldest} (exclusive),
the most recent first, {@code limit} at a
+ * time, with has_more and a next_cursor when there are more.
+ */
+ private void conversationsHistory(HttpExchange http) throws IOException {
+ Map<String, String> params = form(http);
+ String oldest = params.get("oldest");
+ int limit = Integer.parseInt(params.get("limit"));
+ if (rateLimitFollowingPages && params.get("cursor") != null) {
+ rateLimitedRequests.incrementAndGet();
+ http.getResponseHeaders().add("Retry-After", "60");
+ respond(http, 429, "{\"ok\":false,\"error\":\"ratelimited\"}");
+ return;
+ }
+ if (params.get("cursor") != null) {
+ cursors.add(params.get("cursor"));
+ Runnable task = beforeNextFollowingPage.getAndSet(null);
+ if (task != null) {
+ task.run();
+ }
+ }
+ int offset = params.get("cursor") != null ?
Integer.parseInt(params.get("cursor")) : 0;
+
+ List<Integer> range = new ArrayList<>();
+ for (int i = 0; i < channel.size(); i++) {
+ if (oldest == null || new BigDecimal(ts(i)).compareTo(new
BigDecimal(oldest)) > 0) {
+ range.add(i);
+ }
+ }
+ range.sort(Comparator.reverseOrder());
+ List<Integer> page = range.subList(Math.min(offset, range.size()),
Math.min(offset + limit, range.size()));
+ boolean hasMore = offset + limit < range.size();
+
+ String messages = page.stream()
+ .map(i -> "{\"type\":\"message\",\"text\":\"" + channel.get(i)
+ "\",\"ts\":\"" + ts(i) + "\"}")
+ .collect(Collectors.joining(","));
+ respond(http, "{\"ok\":true,\"messages\":[" + messages +
"],\"has_more\":" + hasMore
+ + ",\"response_metadata\":{\"next_cursor\":\"" +
(hasMore ? offset + limit : "") + "\"}}");
+ }
+
+ private static String ts(int index) {
+ return (1700000000 + index) + ".000100";
+ }
+
+ private static Map<String, String> form(HttpExchange http) throws
IOException {
+ Map<String, String> answer = new HashMap<>();
+ String body = new String(http.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
+ for (String pair : body.split("&")) {
+ int idx = pair.indexOf('=');
+ if (idx > 0) {
+ answer.put(URLDecoder.decode(pair.substring(0, idx),
StandardCharsets.UTF_8),
+ URLDecoder.decode(pair.substring(idx + 1),
StandardCharsets.UTF_8));
+ }
+ }
+ return answer;
+ }
+
+ private static void respond(HttpExchange http, String json) throws
IOException {
+ respond(http, 200, json);
+ }
+
+ private static void respond(HttpExchange http, int status, String json)
throws IOException {
+ byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
+ http.getResponseHeaders().add("Content-Type", "application/json;
charset=utf-8");
+ http.sendResponseHeaders(status, bytes.length);
+ try (OutputStream os = http.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index ef55a568c050..ff9c0d0a3386 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -1163,6 +1163,15 @@ the message body as if the route had succeeded, which
usually echoed the request
body now replies with an empty (`null`) body instead of not replying, so the
sender no longer waits until its reply timeout (30 seconds by default).
+=== camel-slack - the consumer reads every new message
+
+When more messages than `maxResults` (default 10) were posted to the channel
since the previous poll, the consumer
+routed only the most recent `maxResults` of them and skipped the older ones.
It now reads the following pages of the
+conversation history in the same poll, so a poll can route more than
`maxResults` messages, with one request per
+`maxResults` messages. For a busy channel, raise `maxResults` to keep the
number of requests within the Slack rate
+limits. If the request for a following page fails (for example rate limited),
the consumer routes the most recent
+messages it has already read, as before, and logs a WARN that the older
messages of that poll were skipped.
+
=== camel-rocketmq - a message whose route failed is consumed again
The consumer acknowledged a message (`CONSUME_SUCCESS`) whenever the processor
returned, also when the route failed:
diff --git
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SlackEndpointBuilderFactory.java
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SlackEndpointBuilderFactory.java
index a0db5f36cbcf..fb820d61dfc1 100644
---
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SlackEndpointBuilderFactory.java
+++
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SlackEndpointBuilderFactory.java
@@ -93,7 +93,9 @@ public interface SlackEndpointBuilderFactory {
return this;
}
/**
- * The Max Result for the poll.
+ * The maximum number of messages that the consumer requests from the
+ * conversation history at a time. When more new messages are
available,
+ * the consumer reads the following pages in the same poll.
*
* The option is a: <code>java.lang.String</code> type.
*