From 280b48f02f1361635458fb86ab2aa8c8cb3bc89b Mon Sep 17 00:00:00 2001 From: Klaas Schuijtemaker Date: Mon, 14 Sep 2026 11:17:33 +0200 Subject: [PATCH] Slack Plugin 6.1.0: receive messages from a channel Adds a `receive-message` action: a scheduled poller reads the channels a process link points at and starts a case per message, or continues a case with the reply in its thread. - Channels are derived from the process links, so a configuration nobody links to costs nothing. - A per-channel cursor keeps the first poll from turning a channel's history into cases; a claim table makes two nodes polling the same channel safe. - Thread replies are read per open conversation, from the position of the case that is furthest behind, because Slack keeps them out of a channel's history. - `post-message` now records the thread it posted in, and can answer inside an existing one. The sandbox app ships a stateful fake Slack (imports/slack-mock) so the whole loop can be driven without a workspace, app or token. --- backend/app/docker-compose.yml | 25 + backend/app/imports/slack-mock/fake_slack.py | 726 ++++++++++++++++++ .../src/main/resources/config/application.yml | 4 + .../example-process.process-link.json | 2 +- .../bpmn/slack-conversation-process.bpmn | 95 +++ .../slack-conversation.case-definition.json | 8 + .../list/slack-conversation.case-list.json | 23 + .../case/tab/slack-conversation.case-tab.json | 14 + ...nversation.schema.document-definition.json | 12 + ...ck-conversation.process-document-link.json | 7 + ...ack-conversation-process.process-link.json | 48 ++ .../config/plugin/slack.pluginconfig.json | 8 +- backend/plugin/build.gradle.kts | 16 + backend/plugin/plugin.properties | 2 +- .../SlackAutoConfiguration.kt | 100 ++- .../slack/client/SlackClient.kt | 213 ++++- .../slack/client/SlackResponse.kt | 133 ++++ .../slack/domain/ProcessedSlackMessage.kt | 53 ++ .../slack/domain/ReceiveMessageProperties.kt | 89 +++ .../slack/domain/SlackChannelCursor.kt | 76 ++ .../slack/domain/SlackConnectionProperties.kt | 82 ++ .../slack/domain/SlackMessage.kt | 181 +++++ .../slack/plugin/SlackPlugin.kt | 125 ++- .../ProcessedSlackMessageRepository.kt | 32 + .../SlackChannelCursorRepository.kt | 22 + .../service/IncomingSlackMessageHandler.kt | 158 ++++ .../service/SlackMessagePollingService.kt | 442 +++++++++++ .../service/SlackMessageProcessStarter.kt | 313 ++++++++ ...10-create-slack-receive-message-tables.xml | 97 +++ .../config/liquibase/slack-master.xml | 25 + .../slack/SlackReceiveMessageIT.kt | 369 +++++++++ .../slack/client/SlackClientTest.kt | 302 ++++++++ .../IncomingSlackMessageHandlerTest.kt | 197 +++++ .../service/SlackMessagePollingServiceTest.kt | 327 ++++++++ .../service/SlackMessageProcessStarterTest.kt | 292 +++++++ .../src/test/resources/config/application.yml | 7 + .../1-0-0/bpmn/slack-message-process.bpmn | 95 +++ .../profile.process-document-link.json | 12 + .../data/conversations-history-response.json | 60 ++ .../data/conversations-replies-response.json | 25 + documentation/release-notes.md | 9 + frontend/projects/plugin/package.json | 2 +- .../post-message-configuration.component.html | 10 + ...ceive-message-configuration.component.html | 80 ++ ...receive-message-configuration.component.ts | 103 +++ .../slack-configuration.component.html | 50 +- .../projects/plugin/src/lib/models/config.ts | 39 +- .../plugin/src/lib/slack-plugin-module.ts | 14 +- .../src/lib/slack-plugin.specification.ts | 123 ++- frontend/projects/plugin/src/public_api.ts | 1 + 50 files changed, 5206 insertions(+), 42 deletions(-) create mode 100644 backend/app/imports/slack-mock/fake_slack.py create mode 100644 backend/app/src/main/resources/config/case/slack-conversation/1-0-0/bpmn/slack-conversation-process.bpmn create mode 100644 backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/definition/slack-conversation.case-definition.json create mode 100644 backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/list/slack-conversation.case-list.json create mode 100644 backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/tab/slack-conversation.case-tab.json create mode 100644 backend/app/src/main/resources/config/case/slack-conversation/1-0-0/document/definition/slack-conversation.schema.document-definition.json create mode 100644 backend/app/src/main/resources/config/case/slack-conversation/1-0-0/process-document-link/slack-conversation.process-document-link.json create mode 100644 backend/app/src/main/resources/config/case/slack-conversation/1-0-0/process-link/slack-conversation-process.process-link.json create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/ProcessedSlackMessage.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/ReceiveMessageProperties.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackChannelCursor.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackConnectionProperties.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackMessage.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/repository/ProcessedSlackMessageRepository.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/repository/SlackChannelCursorRepository.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/IncomingSlackMessageHandler.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessagePollingService.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarter.kt create mode 100644 backend/plugin/src/main/resources/config/liquibase/changelog/20260910-create-slack-receive-message-tables.xml create mode 100644 backend/plugin/src/main/resources/config/liquibase/slack-master.xml create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/SlackReceiveMessageIT.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/client/SlackClientTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/IncomingSlackMessageHandlerTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessagePollingServiceTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarterTest.kt create mode 100644 backend/plugin/src/test/resources/config/case/profile/1-0-0/bpmn/slack-message-process.bpmn create mode 100644 backend/plugin/src/test/resources/data/conversations-history-response.json create mode 100644 backend/plugin/src/test/resources/data/conversations-replies-response.json create mode 100644 frontend/projects/plugin/src/lib/components/receive-message/receive-message-configuration.component.html create mode 100644 frontend/projects/plugin/src/lib/components/receive-message/receive-message-configuration.component.ts diff --git a/backend/app/docker-compose.yml b/backend/app/docker-compose.yml index ee595ac..b6d8375 100644 --- a/backend/app/docker-compose.yml +++ b/backend/app/docker-compose.yml @@ -54,6 +54,31 @@ services: volumes: - plugin-database-data:/var/lib/postgres # persist data even if container shuts down + # Stands in for the Slack Web API, so the sandbox needs no workspace, app or token. Posting + # writes into it and its web UI (http://localhost:8082) is where you type the messages the + # poller turns into cases. See imports/slack-mock/fake_slack.py. + plugin-slack-mock: + container_name: plugin-docker-compose-plugin-slack-mock + image: python:3.13-alpine + ports: + - "8082:8080" + environment: + # Must match the token in config/plugin/slack.pluginconfig.json — the mock rejects + # anything else, the way Slack does, so a fixture that drifts fails visibly. + SLACK_MOCK_TOKEN: xoxb-sandbox-token + SLACK_MOCK_CHANNELS: "C01SANDBOX:sandbox" + volumes: + - ./imports/slack-mock:/srv:ro + command: [ "python", "-u", "/srv/fake_slack.py" ] + healthcheck: + # 127.0.0.1 rather than localhost: the server listens on IPv4 only, and busybox wget + # resolves localhost to ::1 first. + test: [ "CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/_fake/health || exit 1" ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 2s + plugin-rabbitmq: image: rabbitmq:4.1.0-management container_name: plugin-docker-compose-plugin-rabbitmq diff --git a/backend/app/imports/slack-mock/fake_slack.py b/backend/app/imports/slack-mock/fake_slack.py new file mode 100644 index 0000000..3184241 --- /dev/null +++ b/backend/app/imports/slack-mock/fake_slack.py @@ -0,0 +1,726 @@ +""" +A fake Slack Web API for the sandbox application. + +Stands in for `https://slack.com/api` so the plugin can be exercised end to end without a +workspace, a real app or a token: the sandbox posts messages to it, and messages typed into +its web UI come back out of `conversations.history` for the poller to turn into cases. + +Stateful on purpose. A static stub (WireMock and friends) can answer `chat.postMessage`, but +the inbound half of the plugin only becomes visible when a message that was posted, or typed, +is actually *there* on the next read — including the thread it belongs to, the cursor it sits +behind, and the `bot_id` that says the case wrote it rather than a person. + +Implemented with the standard library alone so the container is a stock `python:*-alpine` with +this file mounted into it, and nothing has to be built or published to run the sandbox. + +Endpoints +--------- +Slack, as the plugin uses them (`Authorization: Bearer ` required): + + POST /api/chat.postMessage multipart, form-encoded or JSON + POST /api/files.upload multipart + GET /api/conversations.history + GET /api/conversations.replies + +The harness, which real Slack has no equivalent of (no authentication — it is the test rig): + + GET / web UI: read the channel, type into it + POST /_fake/messages inject a message as if a person typed it + GET /_fake/state every channel and message held right now + POST /_fake/reset forget every message, keep the channels + GET /_fake/health container healthcheck + +Faithful where it matters +------------------------- +- A failed call is `200 OK` with `{"ok": false, "error": "..."}`, which is how Slack reports + every application-level failure. Code that switches on the status code sees success. +- `conversations.history` answers newest first and only returns top-level messages; a reply + inside a thread is reachable through `conversations.replies` alone. That split is the whole + reason the poller does two reads per channel. +- `conversations.replies` answers oldest first, with the thread parent as the first message. +- `oldest` is honoured, exclusively unless `inclusive=true`, and paging is real: `limit`, + `has_more` and an opaque `response_metadata.next_cursor`. +- A message posted through `chat.postMessage` carries `bot_id` and `app_id`, exactly as one + posted by an app does. It is what makes the sandbox show the loop the plugin guards against: + without `includeBotMessages`, a case's own message is not allowed to start another case. + +Environment +----------- + SLACK_MOCK_PORT port to listen on (default 8080) + SLACK_MOCK_TOKEN the only accepted bearer token; any non-empty token if unset + SLACK_MOCK_CHANNELS channels to seed, `id:name` comma-separated + SLACK_MOCK_BOT_ID bot id stamped on messages this app posts + SLACK_MOCK_APP_ID app id stamped on messages this app posts + SLACK_MOCK_BOT_USER user id stamped on messages this app posts + SLACK_MOCK_USER_ID default author of an injected message + SLACK_MOCK_USER_NAME default author name of an injected message +""" + +import json +import os +import re +import threading +import time +from email.parser import BytesParser +from email.policy import default as default_policy +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +PORT = int(os.environ.get("SLACK_MOCK_PORT", "8080")) +EXPECTED_TOKEN = os.environ.get("SLACK_MOCK_TOKEN", "").strip() +SEED_CHANNELS = os.environ.get("SLACK_MOCK_CHANNELS", "C01SANDBOX:sandbox") +BOT_ID = os.environ.get("SLACK_MOCK_BOT_ID", "B0SANDBOXBOT") +APP_ID = os.environ.get("SLACK_MOCK_APP_ID", "A0SANDBOXAPP") +BOT_USER_ID = os.environ.get("SLACK_MOCK_BOT_USER", "U0SANDBOXBOT") +DEFAULT_USER_ID = os.environ.get("SLACK_MOCK_USER_ID", "U0SANDBOXHUM") +DEFAULT_USER_NAME = os.environ.get("SLACK_MOCK_USER_NAME", "sandbox.user") + +# Slack's own ceiling for the `limit` parameter of the conversations methods. +MAX_LIMIT = 1000 +DEFAULT_LIMIT = 100 + + +class Store: + """ + Every channel and its messages, oldest first. + + Held in memory: restarting the container is how you get a clean workspace, and the + channel cursors the plugin keeps in Postgres survive that restart — which is itself worth + seeing, because a cursor pointing past everything the mock still knows about is exactly + the state a re-created Slack workspace would leave behind. + """ + + def __init__(self): + self._lock = threading.Lock() + self._channels = {} + self._messages = {} + # Slack's `ts` doubles as the message id and has to be unique within a channel, so a + # burst of posts inside the same microsecond may not collide. + self._last_ts = 0.0 + + def seed(self, specification): + for entry in filter(None, (part.strip() for part in specification.split(","))): + channel_id, _, name = entry.partition(":") + self.ensure_channel(channel_id.strip(), (name or channel_id).strip()) + + def ensure_channel(self, channel_id, name=None): + with self._lock: + channel = self._channels.get(channel_id) + if channel is None: + channel = {"id": channel_id, "name": name or channel_id} + self._channels[channel_id] = channel + self._messages[channel_id] = [] + return channel + + def resolve(self, specification): + """ + Returns the channel id for an id, a `#name` or a bare name, or `None`. + + Names are accepted because `chat.postMessage` accepts them and because a sandbox is + friendlier when `#sandbox` works; the read methods are handed whatever this returns, + so a link configured with a name still finds its channel here. + """ + if not specification: + return None + candidate = specification.strip().lstrip("#") + with self._lock: + if candidate in self._channels: + return candidate + for channel_id, channel in self._channels.items(): + if channel["name"] == candidate: + return channel_id + return None + + def channels(self): + with self._lock: + return [dict(channel) for channel in self._channels.values()] + + def messages(self, channel_id): + with self._lock: + return [dict(message) for message in self._messages.get(channel_id, [])] + + def find(self, channel_id, ts): + with self._lock: + for message in self._messages.get(channel_id, []): + if message["ts"] == ts: + return dict(message) + return None + + def add(self, channel_id, message): + with self._lock: + now = time.time() + self._last_ts = max(now, self._last_ts + 0.000001) + stored = dict(message) + stored["type"] = "message" + stored["ts"] = "%.6f" % self._last_ts + # A thread parent reports the thread it opened, the same way Slack does, so + # `thread_ts == ts` distinguishes it from a reply. + if stored.get("thread_ts") is None: + stored.pop("thread_ts", None) + self._messages.setdefault(channel_id, []).append(stored) + return dict(stored) + + def reset(self): + with self._lock: + for channel_id in self._messages: + self._messages[channel_id] = [] + + +STORE = Store() + + +def error(code): + return {"ok": False, "error": code} + + +def page(messages, limit, cursor): + """ + Cuts one page out of `messages` and reports how to ask for the next. + + The cursor is an offset dressed up as an opaque string, because a client that parses it + instead of echoing it back would work here and break against Slack. + """ + offset = 0 + if cursor: + match = re.fullmatch(r"offset:(\d+)", cursor) + if match is None: + return None, None + offset = int(match.group(1)) + + window = messages[offset:offset + limit] + remaining = len(messages) > offset + limit + return window, ("offset:%d" % (offset + limit) if remaining else None) + + +def in_window(ts, oldest, latest, inclusive): + value = float(ts) + if oldest is not None: + if value < oldest or (value == oldest and not inclusive): + return False + if latest is not None: + if value > latest or (value == latest and not inclusive): + return False + return True + + +def as_float(value): + try: + return float(value) if value not in (None, "") else None + except ValueError: + return None + + +def as_bool(value, fallback=False): + if value is None: + return fallback + return str(value).strip().lower() in ("1", "true", "yes") + + +def as_limit(value): + limit = DEFAULT_LIMIT + try: + if value not in (None, ""): + limit = int(value) + except ValueError: + return DEFAULT_LIMIT + return max(1, min(limit, MAX_LIMIT)) + + +def public_message(message): + """The message as Slack serialises it in a conversations response.""" + return {key: value for key, value in message.items() if value is not None} + + +class Handler(BaseHTTPRequestHandler): + # Keep-alive, because the plugin's RestClient reuses connections and a poll of a paged + # channel is several requests in a row. + protocol_version = "HTTP/1.1" + server_version = "fake-slack/1.0" + + # -- routing ---------------------------------------------------------------------- + + def do_GET(self): + self._consume_body() + route = urlparse(self.path) + query = {key: values[-1] for key, values in parse_qs(route.query).items()} + + if route.path in ("/", "/index.html"): + return self._send_html(UI_HTML) + if route.path == "/_fake/health": + return self._send_json({"ok": True, "channels": len(STORE.channels())}) + if route.path == "/_fake/state": + return self._send_json(self._state(query.get("channel"))) + if route.path == "/api/conversations.history": + return self._authenticated(lambda: self._conversations_history(query)) + if route.path == "/api/conversations.replies": + return self._authenticated(lambda: self._conversations_replies(query)) + if route.path.startswith("/api/"): + return self._send_json(error("unknown_method")) + return self._send_json(error("not_found"), status=404) + + def do_POST(self): + body, content_type = self._read_body() + route = urlparse(self.path) + + if route.path == "/_fake/messages": + return self._send_json(self._inject(self._parse_body(body, content_type))) + if route.path == "/_fake/reset": + STORE.reset() + return self._send_json({"ok": True}) + if route.path == "/api/chat.postMessage": + form = self._parse_body(body, content_type) + return self._authenticated(lambda: self._chat_post_message(form)) + if route.path == "/api/files.upload": + form = self._parse_body(body, content_type) + return self._authenticated(lambda: self._files_upload(form)) + if route.path.startswith("/api/"): + return self._send_json(error("unknown_method")) + return self._send_json(error("not_found"), status=404) + + # -- Slack methods ---------------------------------------------------------------- + + def _chat_post_message(self, form): + channel_id = STORE.resolve(form.get("channel")) + if channel_id is None: + return error("channel_not_found") + + text = form.get("text") + if not text: + return error("no_text") + + thread_ts = form.get("thread_ts") or None + if thread_ts and STORE.find(channel_id, thread_ts) is None: + return error("thread_not_found") + + message = STORE.add( + channel_id, + { + "text": text, + "user": BOT_USER_ID, + "username": form.get("username") or "Valtimo messenger", + "bot_id": BOT_ID, + "app_id": APP_ID, + "thread_ts": thread_ts, + }, + ) + self._log("posted %s in %s%s", message["ts"], channel_id, + " (thread %s)" % thread_ts if thread_ts else "") + return { + "ok": True, + "channel": channel_id, + "ts": message["ts"], + "message": public_message(message), + } + + def _files_upload(self, form): + # `channels` is a comma-separated list, and the message may arrive under either the + # current name (`initial_comment`) or the legacy one the plugin still sends. + specifications = [part.strip() for part in (form.get("channels") or "").split(",")] + channel_ids = [STORE.resolve(specification) for specification in specifications if specification] + if not channel_ids or None in channel_ids: + return error("channel_not_found") + + file_name = form.get("filename") or form.get("file_name") or "upload" + content = form.get("content") or b"" + if isinstance(content, str): + content = content.encode() + + file_id = "F%d" % int(time.time() * 1000) + descriptor = { + "id": file_id, + "name": file_name, + "title": form.get("title") or file_name, + "filetype": form.get("filetype") or "", + "mimetype": form.get("mimetype") or "application/octet-stream", + "size": len(content), + } + + for channel_id in channel_ids: + STORE.add( + channel_id, + { + "text": form.get("initial_comment") or form.get("initial_message") or "", + "user": BOT_USER_ID, + "username": "Valtimo messenger", + "bot_id": BOT_ID, + "app_id": APP_ID, + "subtype": "file_share", + "files": [descriptor], + }, + ) + self._log("uploaded '%s' (%d bytes) to %s", file_name, len(content), channel_id) + + return {"ok": True, "file": descriptor, "channels": channel_ids} + + def _conversations_history(self, query): + channel_id = STORE.resolve(query.get("channel")) + if channel_id is None: + return error("channel_not_found") + + oldest = as_float(query.get("oldest")) + latest = as_float(query.get("latest")) + inclusive = as_bool(query.get("inclusive")) + + # Top-level only. A reply lives in its thread and nowhere else, which is why the + # poller has to ask about threads separately. + candidates = [ + message + for message in STORE.messages(channel_id) + if message.get("thread_ts") in (None, message["ts"]) + and in_window(message["ts"], oldest, latest, inclusive) + ] + candidates.reverse() + + window, next_cursor = page(candidates, as_limit(query.get("limit")), query.get("cursor")) + if window is None: + return error("invalid_cursor") + + self._log( + "history of %s: %d message(s)%s", channel_id, len(window), + " (more to come)" if next_cursor else "", + ) + return self._paged(window, next_cursor) + + def _conversations_replies(self, query): + channel_id = STORE.resolve(query.get("channel")) + if channel_id is None: + return error("channel_not_found") + + thread_ts = query.get("ts") + parent = STORE.find(channel_id, thread_ts) if thread_ts else None + if parent is None: + return error("thread_not_found") + + oldest = as_float(query.get("oldest")) + latest = as_float(query.get("latest")) + inclusive = as_bool(query.get("inclusive")) + + replies = [ + message + for message in STORE.messages(channel_id) + if message.get("thread_ts") == thread_ts + and message["ts"] != thread_ts + and in_window(message["ts"], oldest, latest, inclusive) + ] + + # The parent is always the first message of the answer, whatever `oldest` says, and it + # advertises the thread it opened. + parent["thread_ts"] = thread_ts + parent["reply_count"] = len(replies) + window, next_cursor = page([parent] + replies, as_limit(query.get("limit")), query.get("cursor")) + if window is None: + return error("invalid_cursor") + + self._log("thread %s of %s: %d reply/replies", thread_ts, channel_id, len(replies)) + return self._paged(window, next_cursor) + + def _paged(self, messages, next_cursor): + response = { + "ok": True, + "messages": [public_message(message) for message in messages], + "has_more": next_cursor is not None, + } + if next_cursor is not None: + response["response_metadata"] = {"next_cursor": next_cursor} + return response + + # -- harness ---------------------------------------------------------------------- + + def _inject(self, form): + """Adds a message as if a person had typed it: no `bot_id`, so the poller takes it.""" + specification = form.get("channel") + channel_id = STORE.resolve(specification) + if channel_id is None: + if not specification: + return error("channel_not_found") + # Unlike the Slack methods, the harness creates what it is asked for: needing to + # seed a channel before you can type into it would be friction with no upside. + channel_id = STORE.ensure_channel(specification.strip().lstrip("#"))["id"] + + thread_ts = form.get("thread_ts") or None + if thread_ts and STORE.find(channel_id, thread_ts) is None: + return error("thread_not_found") + + message = STORE.add( + channel_id, + { + "text": form.get("text") or "", + "user": form.get("user") or DEFAULT_USER_ID, + "username": form.get("username") or DEFAULT_USER_NAME, + "subtype": form.get("subtype") or None, + "thread_ts": thread_ts, + }, + ) + self._log("injected %s in %s%s", message["ts"], channel_id, + " (thread %s)" % thread_ts if thread_ts else "") + return {"ok": True, "channel": channel_id, "ts": message["ts"], "message": public_message(message)} + + def _state(self, channel): + channels = STORE.channels() + wanted = STORE.resolve(channel) if channel else None + return { + "ok": True, + "channels": channels, + "messages": { + entry["id"]: [public_message(message) for message in STORE.messages(entry["id"])] + for entry in channels + if wanted is None or entry["id"] == wanted + }, + } + + # -- plumbing --------------------------------------------------------------------- + + def _authenticated(self, handle): + """ + Slack rejects an unauthenticated call with `ok: false` and HTTP 200, not with a 401. + + Worth reproducing: a plugin configuration with an empty token has to surface as a + Slack error in the log, which is what the real thing does. + """ + header = self.headers.get("Authorization", "") + token = header[len("Bearer "):].strip() if header.startswith("Bearer ") else "" + if not token: + self._log("rejected an unauthenticated call to %s", self.path) + return self._send_json(error("not_authed")) + if EXPECTED_TOKEN and token != EXPECTED_TOKEN: + self._log("rejected token '%s' on %s", token, self.path) + return self._send_json(error("invalid_auth")) + return self._send_json(handle()) + + def _read_body(self): + length = int(self.headers.get("Content-Length") or 0) + return (self.rfile.read(length) if length else b""), (self.headers.get("Content-Type") or "") + + def _consume_body(self): + self._read_body() + + def _parse_body(self, body, content_type): + """ + Reads a request body as a flat field map. + + Three encodings, because the plugin posts multipart, `curl -d` posts form-encoded and + anything hand-written posts JSON — and a fake nobody can drive by hand is only half a + test rig. + """ + base_type = content_type.split(";")[0].strip().lower() + + if base_type == "multipart/form-data": + parsed = BytesParser(policy=default_policy).parsebytes( + b"Content-Type: " + content_type.encode() + b"\r\n\r\n" + body + ) + fields = {} + for part in parsed.iter_parts(): + name = part.get_param("name", header="content-disposition") + if name is None: + continue + payload = part.get_payload(decode=True) or b"" + file_name = part.get_filename() + fields[name] = payload if name == "content" else payload.decode("utf-8", "replace") + if file_name and not fields.get("filename"): + fields["filename"] = file_name + return fields + + if base_type == "application/json": + try: + parsed = json.loads(body or b"{}") + except json.JSONDecodeError: + return {} + return {key: value for key, value in parsed.items()} if isinstance(parsed, dict) else {} + + return {key: values[-1] for key, values in parse_qs(body.decode("utf-8", "replace")).items()} + + def _send_json(self, payload, status=200): + self._send(status, "application/json; charset=utf-8", json.dumps(payload).encode()) + + def _send_html(self, html): + self._send(200, "text/html; charset=utf-8", html.encode()) + + def _send(self, status, content_type, body): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + # The UI is served from this same origin, but a developer poking at the mock from the + # Valtimo frontend on another port should not have to think about it. + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self): + self._consume_body() + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") + self.send_header("Content-Length", "0") + self.end_headers() + + def _log(self, template, *arguments): + print("fake-slack " + (template % arguments), flush=True) + + def log_message(self, template, *arguments): + # The default access log would drown the interesting lines: the UI polls every two + # seconds. Errors still come through `log_error`, which is separate. + pass + + +UI_HTML = """ + + + +Fake Slack — Valtimo sandbox + + + +
+

Fake Slack

+ + +
+
    + +
    + + +
    + + + +""" + + +def main(): + STORE.seed(SEED_CHANNELS) + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print( + "fake-slack listening on :%d — channels %s, token %s" + % ( + PORT, + ", ".join(channel["id"] for channel in STORE.channels()) or "(none)", + "'%s'" % EXPECTED_TOKEN if EXPECTED_TOKEN else "(any)", + ), + flush=True, + ) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/backend/app/src/main/resources/config/application.yml b/backend/app/src/main/resources/config/application.yml index 83d2ae1..7eedb50 100644 --- a/backend/app/src/main/resources/config/application.yml +++ b/backend/app/src/main/resources/config/application.yml @@ -82,6 +82,10 @@ valtimo: - localhost plugin: encryption-secret: "abcdefghijklmnop" + slack: + # Far more often than the five-minute default, so a message typed into the fake Slack UI + # (http://localhost:8082) becomes a case while you are still looking at it. + poll-cron: "*/15 * * * * *" docker: filter: enabled: true diff --git a/backend/app/src/main/resources/config/case/example/1-0-0/process-link/example-process.process-link.json b/backend/app/src/main/resources/config/case/example/1-0-0/process-link/example-process.process-link.json index c753d5f..fca880d 100644 --- a/backend/app/src/main/resources/config/case/example/1-0-0/process-link/example-process.process-link.json +++ b/backend/app/src/main/resources/config/case/example/1-0-0/process-link/example-process.process-link.json @@ -12,7 +12,7 @@ "pluginActionDefinitionKey": "post-message", "pluginConfigurationId": "8661ca7e-9ae7-4688-90be-68c3ca828de1", "actionProperties": { - "channel": "valtimo-data", + "channel": "C01SANDBOX", "message": "Hello World!" } } diff --git a/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/bpmn/slack-conversation-process.bpmn b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/bpmn/slack-conversation-process.bpmn new file mode 100644 index 0000000..1b0edb8 --- /dev/null +++ b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/bpmn/slack-conversation-process.bpmn @@ -0,0 +1,95 @@ + + + + + + + + + + Flow_1 + + + + Flow_1 + Flow_2 + + + + Flow_2 + Flow_3 + + + Flow_3 + Flow_4 + + + Flow_4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/definition/slack-conversation.case-definition.json b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/definition/slack-conversation.case-definition.json new file mode 100644 index 0000000..5485ac1 --- /dev/null +++ b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/definition/slack-conversation.case-definition.json @@ -0,0 +1,8 @@ +{ + "key": "slack-conversation", + "name": "Slack Conversation", + "versionTag": "1.0.0", + "final": false, + "canHaveAssignee": true, + "autoAssignTasks": true +} diff --git a/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/list/slack-conversation.case-list.json b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/list/slack-conversation.case-list.json new file mode 100644 index 0000000..6958c74 --- /dev/null +++ b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/list/slack-conversation.case-list.json @@ -0,0 +1,23 @@ +[ + { + "key": "createdOn", + "path": "case:createdOn", + "displayType": { + "type": "date", + "displayTypeParameters": {} + }, + "sortable": true, + "defaultSort": "DESC", + "order": 0 + }, + { + "key": "createdBy", + "path": "case:createdBy", + "displayType": { + "type": "text", + "displayTypeParameters": {} + }, + "sortable": true, + "order": 1 + } +] diff --git a/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/tab/slack-conversation.case-tab.json b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/tab/slack-conversation.case-tab.json new file mode 100644 index 0000000..3288f15 --- /dev/null +++ b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/case/tab/slack-conversation.case-tab.json @@ -0,0 +1,14 @@ +[ + { + "name": "Progress", + "key": "progress", + "type": "standard", + "contentKey": "progress" + }, + { + "name": "Audit", + "key": "audit", + "type": "standard", + "contentKey": "audit" + } +] diff --git a/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/document/definition/slack-conversation.schema.document-definition.json b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/document/definition/slack-conversation.schema.document-definition.json new file mode 100644 index 0000000..ce502e5 --- /dev/null +++ b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/document/definition/slack-conversation.schema.document-definition.json @@ -0,0 +1,12 @@ +{ + "$id": "slack-conversation.schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Slack Conversation", + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "Free text. The Slack message itself arrives as process variables (slackChannel, slackThreadTs, slackMessageText, ...), not as document content, so this case is created empty." + } + } +} diff --git a/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/process-document-link/slack-conversation.process-document-link.json b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/process-document-link/slack-conversation.process-document-link.json new file mode 100644 index 0000000..4e5b059 --- /dev/null +++ b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/process-document-link/slack-conversation.process-document-link.json @@ -0,0 +1,7 @@ +[ + { + "processDefinitionKey": "slack-conversation-process", + "canInitializeDocument": true, + "startableByUser": false + } +] diff --git a/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/process-link/slack-conversation-process.process-link.json b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/process-link/slack-conversation-process.process-link.json new file mode 100644 index 0000000..ccf02a6 --- /dev/null +++ b/backend/app/src/main/resources/config/case/slack-conversation/1-0-0/process-link/slack-conversation-process.process-link.json @@ -0,0 +1,48 @@ +[ + { + "activityId": "slack-message-received", + "activityType": "bpmn:MessageStartEvent:start", + "processLinkType": "plugin", + "pluginActionDefinitionKey": "receive-message", + "pluginConfigurationId": "8661ca7e-9ae7-4688-90be-68c3ca828de1", + "actionProperties": { + "channel": "C01SANDBOX", + "threadScope": "THREAD_STARTS_ONLY" + } + }, + { + "activityId": "slack-acknowledge", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "plugin", + "pluginActionDefinitionKey": "post-message", + "pluginConfigurationId": "8661ca7e-9ae7-4688-90be-68c3ca828de1", + "actionProperties": { + "channel": "pv:slackChannel", + "message": "Thanks, we picked this up as a case. What is it about?", + "threadTs": "pv:slackThreadTs" + } + }, + { + "activityId": "slack-await-reply", + "activityType": "bpmn:ReceiveTask:end", + "processLinkType": "plugin", + "pluginActionDefinitionKey": "receive-message", + "pluginConfigurationId": "8661ca7e-9ae7-4688-90be-68c3ca828de1", + "actionProperties": { + "channel": "C01SANDBOX", + "threadScope": "THREAD_REPLIES_ONLY" + } + }, + { + "activityId": "slack-confirm", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "plugin", + "pluginActionDefinitionKey": "post-message", + "pluginConfigurationId": "8661ca7e-9ae7-4688-90be-68c3ca828de1", + "actionProperties": { + "channel": "pv:slackChannel", + "message": "Got it, we will take it from here.", + "threadTs": "pv:slackThreadTs" + } + } +] diff --git a/backend/app/src/main/resources/config/plugin/slack.pluginconfig.json b/backend/app/src/main/resources/config/plugin/slack.pluginconfig.json index 20fff00..fabda38 100644 --- a/backend/app/src/main/resources/config/plugin/slack.pluginconfig.json +++ b/backend/app/src/main/resources/config/plugin/slack.pluginconfig.json @@ -4,8 +4,12 @@ "title": "Slack configuration (Autodeployed)", "pluginDefinitionKey": "slack", "properties": { - "url": "https://www.slack.com/", - "token": "xoxb-fake-token" + "url": "http://localhost:8082/", + "token": "xoxb-sandbox-token", + "messagesPerPage": 20, + "maxPagesPerPoll": 5, + "maxThreadsPerPoll": 10, + "initialLookbackMinutes": 60 } } ] diff --git a/backend/plugin/build.gradle.kts b/backend/plugin/build.gradle.kts index 3393f54..990fbd4 100644 --- a/backend/plugin/build.gradle.kts +++ b/backend/plugin/build.gradle.kts @@ -17,6 +17,7 @@ val kotlinLoggingVersion: String by project val okhttpVersion: String by project val mockitoKotlinVersion: String by project +val operatonVersion: String by project dockerCompose { setProjectName("slack") @@ -28,16 +29,30 @@ dockerCompose { } dependencies { + compileOnly("com.ritense.valtimo:authorization") + compileOnly("com.ritense.valtimo:case") + compileOnly("com.ritense.valtimo:contract") compileOnly("com.ritense.valtimo:core") compileOnly("com.ritense.valtimo:plugin-valtimo") + compileOnly("com.ritense.valtimo:process-document") compileOnly("com.ritense.valtimo:temporary-resource-storage") compileOnly("com.ritense.valtimo:value-resolver") + compileOnly("org.operaton.bpm:operaton-engine:$operatonVersion") compileOnly("org.springframework.boot:spring-boot-starter-webflux") + compileOnly("org.springframework.boot:spring-boot-autoconfigure") + compileOnly("org.springframework.boot:spring-boot-starter-data-jpa") + // For @SchedulerLock on the poller. compileOnly like the Valtimo modules: a Valtimo + // application already has ShedLock on its classpath, because core's + // SchedulerAutoConfiguration builds the JdbcTemplateLockProvider the annotation needs. + compileOnly("net.javacrumbs.shedlock:shedlock-spring") + + compileOnly("com.fasterxml.jackson.core:jackson-databind") compileOnly("io.github.oshai:kotlin-logging-jvm:$kotlinLoggingVersion") // Testing + testImplementation("com.ritense.valtimo:authorization") testImplementation("com.ritense.valtimo:building-block") testImplementation("com.ritense.valtimo:contract") testImplementation("com.ritense.valtimo:core") @@ -45,6 +60,7 @@ dependencies { testImplementation("com.ritense.valtimo:temporary-resource-storage") testImplementation("com.ritense.valtimo:test-utils-common") + testImplementation("org.springframework.boot:spring-boot-starter-data-jpa") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.postgresql:postgresql") diff --git a/backend/plugin/plugin.properties b/backend/plugin/plugin.properties index 09ef0b9..2032f6a 100644 --- a/backend/plugin/plugin.properties +++ b/backend/plugin/plugin.properties @@ -1,3 +1,3 @@ pluginGroupId=com.ritense.valtimoplugins pluginArtifactId=slack -pluginVersion=6.0.1 +pluginVersion=6.1.0 diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/autoconfiguration/SlackAutoConfiguration.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/autoconfiguration/SlackAutoConfiguration.kt index 271ac82..770fcb8 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/autoconfiguration/SlackAutoConfiguration.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/autoconfiguration/SlackAutoConfiguration.kt @@ -16,20 +16,45 @@ package com.ritense.valtimoplugins.slack.autoconfiguration +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.case.service.CaseDefinitionService import com.ritense.plugin.service.PluginService +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository import com.ritense.resource.service.TemporaryResourceStorageService +import com.ritense.valtimo.contract.config.LiquibaseMasterChangeLogLocation +import com.ritense.valtimo.service.ProcessPropertyService import com.ritense.valtimoplugins.slack.client.SlackClient +import com.ritense.valtimoplugins.slack.domain.ProcessedSlackMessage import com.ritense.valtimoplugins.slack.plugin.SlackPluginFactory +import com.ritense.valtimoplugins.slack.repository.ProcessedSlackMessageRepository +import com.ritense.valtimoplugins.slack.repository.SlackChannelCursorRepository +import com.ritense.valtimoplugins.slack.service.IncomingSlackMessageHandler +import com.ritense.valtimoplugins.slack.service.SlackMessagePollingService +import com.ritense.valtimoplugins.slack.service.SlackMessageProcessStarter +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.engine.RuntimeService +import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.domain.EntityScan import org.springframework.context.annotation.Bean +import org.springframework.core.Ordered.HIGHEST_PRECEDENCE +import org.springframework.core.annotation.Order +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.web.client.RestClient @AutoConfiguration +@EnableScheduling +@EnableJpaRepositories(basePackageClasses = [ProcessedSlackMessageRepository::class]) +@EntityScan(basePackageClasses = [ProcessedSlackMessage::class]) class SlackAutoConfiguration { @Bean @ConditionalOnMissingBean(SlackClient::class) - fun slackClient(restClientBuilder: RestClient.Builder): SlackClient = SlackClient(restClientBuilder, null, null) + fun slackClient(restClientBuilder: RestClient.Builder): SlackClient = SlackClient(restClientBuilder) @Bean @ConditionalOnMissingBean(SlackPluginFactory::class) @@ -38,4 +63,77 @@ class SlackAutoConfiguration { slackClient: SlackClient, storageService: TemporaryResourceStorageService, ): SlackPluginFactory = SlackPluginFactory(pluginService, slackClient, storageService) + + @Bean + @ConditionalOnMissingBean(SlackMessageProcessStarter::class) + fun slackMessageProcessStarter( + runtimeService: RuntimeService, + repositoryService: RepositoryService, + processPropertyService: ProcessPropertyService, + processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, + processDocumentService: ProcessDocumentService, + caseDefinitionService: CaseDefinitionService, + ): SlackMessageProcessStarter = + SlackMessageProcessStarter( + runtimeService, + repositoryService, + processPropertyService, + processDefinitionCaseDefinitionService, + processDocumentService, + caseDefinitionService, + ) + + @Bean + @ConditionalOnMissingBean(IncomingSlackMessageHandler::class) + fun incomingSlackMessageHandler( + processedSlackMessageRepository: ProcessedSlackMessageRepository, + slackMessageProcessStarter: SlackMessageProcessStarter, + objectMapper: ObjectMapper, + ): IncomingSlackMessageHandler = + IncomingSlackMessageHandler( + processedSlackMessageRepository, + slackMessageProcessStarter, + objectMapper, + ) + + /** + * The poller is the only bean behind a switch. + * + * Turning it off leaves the plugin fully installed and configurable, and posting still + * works — it only stops this node from reading any channel. That is what you want on a + * node that should not compete for messages (a migration runner, a local machine pointed + * at a shared test workspace) and what makes the plugin safe to deploy before the Slack + * app exists. + */ + @Bean + @ConditionalOnMissingBean(SlackMessagePollingService::class) + @ConditionalOnProperty(value = ["valtimo.slack.polling-enabled"], matchIfMissing = true) + fun slackMessagePollingService( + pluginProcessLinkRepository: ValtimoPluginProcessLinkRepository, + pluginService: PluginService, + slackClient: SlackClient, + incomingSlackMessageHandler: IncomingSlackMessageHandler, + slackMessageProcessStarter: SlackMessageProcessStarter, + processedSlackMessageRepository: ProcessedSlackMessageRepository, + slackChannelCursorRepository: SlackChannelCursorRepository, + objectMapper: ObjectMapper, + @Value("\${valtimo.slack.retention-days:90}") retentionDays: Long, + ): SlackMessagePollingService = + SlackMessagePollingService( + pluginProcessLinkRepository, + pluginService, + slackClient, + incomingSlackMessageHandler, + slackMessageProcessStarter, + processedSlackMessageRepository, + slackChannelCursorRepository, + objectMapper, + retentionDays, + ) + + @Order(HIGHEST_PRECEDENCE + 36) + @Bean + @ConditionalOnMissingBean(name = ["slackLiquibaseMasterChangeLogLocation"]) + fun slackLiquibaseMasterChangeLogLocation(): LiquibaseMasterChangeLogLocation = + LiquibaseMasterChangeLogLocation("config/liquibase/slack-master.xml") } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/client/SlackClient.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/client/SlackClient.kt index 4d3e184..41c6ca8 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/client/SlackClient.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/client/SlackClient.kt @@ -17,6 +17,8 @@ package com.ritense.valtimoplugins.slack.client import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimoplugins.slack.domain.SlackConnectionProperties +import com.ritense.valtimoplugins.slack.domain.SlackMessage import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.core.io.InputStreamResource import org.springframework.http.MediaType @@ -26,63 +28,223 @@ import org.springframework.util.LinkedMultiValueMap import org.springframework.web.client.RestClient import org.springframework.web.client.body import java.io.InputStream -import java.net.URI +/** + * Talks to the Slack Web API. + * + * One instance serves every plugin configuration, and the workspace to address is therefore a + * parameter of each call rather than state on the client. It used to be the latter, which was + * a real hazard rather than a stylistic one: this is a singleton, two process instances + * configured against two workspaces post on two threads, and between one thread setting the + * token and using it the other could replace it — sending one workspace's message to the + * other, with its credentials. + */ @Component @SkipComponentScan class SlackClient( private val restClientBuilder: RestClient.Builder, - var baseUri: URI?, - var token: String?, ) { /** * https://api.slack.com/methods/chat.postMessage + * + * The returned `ts` is the id of the posted message, and the thread a reply to it will + * carry. A case that wants an answer has to keep it. */ fun chatPostMessage( + connection: SlackConnectionProperties, channel: String, message: String, - ) { + threadTs: String? = null, + ): ChatPostMessageResponse { logger.debug { "Post message in slack ('$message')" } val multipartFormData = - mutableMapOf( + mutableMapOf( "channel" to channel, "text" to message, ) - post("/api/chat.postMessage", multipartFormData) + threadTs?.takeIf { it.isNotBlank() }?.let { multipartFormData["thread_ts"] = it } + + return post(connection, "/api/chat.postMessage", multipartFormData) } /** * https://api.slack.com/methods/files.upload */ fun filesUpload( + connection: SlackConnectionProperties, channels: String, message: String?, fileName: String, file: InputStream, ) { logger.debug { "Post message with file in slack ('$message', '$fileName')" } - val fileNameParts = fileName.split('.') + + // Split on the last dot rather than the first, and tolerate there being none at all: + // "verslag.2026.pdf" is a pdf, and "LICENSE" is a file Slack is happy to take without + // being told its type. Indexing into a split on '.' threw on both. + val extension = fileName.substringAfterLast('.', "") val multipartFormData = mutableMapOf( "channels" to channels, "filename" to fileName, - "title" to fileNameParts[0], - "filetype" to fileNameParts[1], + "title" to fileName.substringBeforeLast('.'), "content" to InputStreamResource(file), ) + extension.takeIf { it.isNotBlank() }?.let { multipartFormData["filetype"] = it } message?.let { multipartFormData["initial_message"] = it } - post("/api/files.upload", multipartFormData) + post(connection, "/api/files.upload", multipartFormData) + } + + /** + * https://api.slack.com/methods/conversations.history + * + * Reads the messages posted in [channel] after [oldest], oldest first, following Slack's + * pagination until the channel is exhausted or [SlackConnectionProperties.maxPagesPerPoll] + * pages have been read. + * + * Slack answers newest first, so the pages walk backwards in time towards [oldest] and + * the result is reversed before it is returned. Handling them in the order they were + * posted is what lets a channel cursor advance safely: a run that dies halfway has read a + * contiguous stretch, not the newest few with holes behind them. + * + * Note that only top-level messages come back. Replies inside a thread are reachable + * through [conversationsReplies] alone, which is why the poller asks for both. + */ + fun conversationsHistory( + connection: SlackConnectionProperties, + channel: String, + oldest: String?, + ): PagedMessages = + readConversation( + path = "/api/conversations.history", + connection = connection, + channel = channel, + oldest = oldest, + threadTs = null, + newestFirst = true, + ) + + /** + * https://api.slack.com/methods/conversations.replies + * + * Reads the replies in the thread opened by [threadTs] that were posted after [oldest], + * oldest first. The thread parent itself is part of Slack's answer and is filtered out: + * it was already handled by [conversationsHistory] when it was posted. + */ + fun conversationsReplies( + connection: SlackConnectionProperties, + channel: String, + threadTs: String, + oldest: String?, + ): PagedMessages = + readConversation( + path = "/api/conversations.replies", + connection = connection, + channel = channel, + oldest = oldest, + threadTs = threadTs, + // Unlike conversations.history, this method answers oldest first - "the earliest + // messages in the time range are returned first" - and pages forward in time. + newestFirst = false, + ).let { paged -> paged.copy(messages = paged.messages.filterNot { it.ts == threadTs }) } + + private fun readConversation( + path: String, + connection: SlackConnectionProperties, + channel: String, + oldest: String?, + threadTs: String?, + newestFirst: Boolean, + ): PagedMessages { + connection.validate() + + val messages = mutableListOf() + var cursor: String? = null + var page = 0 + + do { + val response = + get( + connection = connection, + path = path, + queryParameters = + buildMap { + put("channel", channel) + put("limit", connection.messagesPerPage.toString()) + threadTs?.let { put("ts", it) } + // Exclusive, so a cursor sitting exactly on the newest handled + // message does not hand that message back on every poll. + oldest?.let { + put("oldest", it) + put("inclusive", "false") + } + cursor?.let { put("cursor", it) } + }, + ) + + messages += response.messages.mapNotNull { it.toSlackMessage(channel) } + cursor = response.nextCursor.takeIf { response.hasMore } + page++ + } while (cursor != null && page < connection.maxPagesPerPoll) + + return PagedMessages( + // Always oldest first, whichever way Slack served it. + // + // Reversed rather than sorted: Slack's ordering within a page is authoritative, + // and sorting on ts would impose a total order on values that are only + // approximately comparable across pages. + // + // Reversing a reply page too would be a silent loss rather than a cosmetic one: + // the poller signals the waiting execution with each message in turn, so handing + // it the newest reply first moves the case past its catch event, and the earlier + // reply - the answer the case was actually waiting for - then matches no waiting + // execution and is thrown away with its claim already written. + messages = if (newestFirst) messages.reversed() else messages, + truncated = cursor != null, + ) + } + + private fun get( + connection: SlackConnectionProperties, + path: String, + queryParameters: Map, + ): ConversationsResponse { + val response = + restClientBuilder + .clone() + .build() + .get() + .uri { builder -> + builder + .scheme(connection.baseUri.scheme) + .host(connection.baseUri.host) + .port(connection.baseUri.port) + .path(connection.baseUri.path) + .path(path) + .also { queryParameters.forEach { (key, value) -> it.queryParam(key, value) } } + .build() + }.headers { + it.setBearerAuth(connection.token) + }.accept(MediaType.APPLICATION_JSON) + .retrieve() + .body() + + if (response?.ok != true) { + throw SlackException(response?.error) + } + return response } private fun post( + connection: SlackConnectionProperties, path: String, multipartFormData: Map, - ) { + ): ChatPostMessageResponse { val body = LinkedMultiValueMap() multipartFormData.forEach { body.add(it.key, it.value) } @@ -93,23 +255,40 @@ class SlackClient( .post() .uri { it - .scheme(baseUri!!.scheme) - .host(baseUri!!.host) - .path(baseUri!!.path) + .scheme(connection.baseUri.scheme) + .host(connection.baseUri.host) + .path(connection.baseUri.path) .path(path) - .port(baseUri!!.port) + .port(connection.baseUri.port) .build() }.headers { it.contentType = MULTIPART_FORM_DATA - it.setBearerAuth(token!!) + it.setBearerAuth(connection.token) }.accept(MediaType.APPLICATION_JSON) .body(body) .retrieve() - .body() + .body() if (response?.ok != true) { throw SlackException(response?.error) } + return response + } + + /** + * The messages of one read, and whether the page budget ran out before the channel did. + * + * [truncated] exists so the caller can say so out loud: moving the cursor past a + * truncated read skips the messages that were never fetched, and a skipped message is a + * case that never starts. See `SlackMessagePollingService`. + */ + data class PagedMessages( + val messages: List = emptyList(), + val truncated: Boolean = false, + ) { + /** The newest timestamp read, or `null` when nothing came back. */ + fun latestTimestamp(): String? = + messages.fold(null as String?) { latest, message -> SlackMessage.latestTimestamp(latest, message.ts) } } companion object { diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/client/SlackResponse.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/client/SlackResponse.kt index aaa092d..69aeb03 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/client/SlackResponse.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/client/SlackResponse.kt @@ -16,7 +16,140 @@ package com.ritense.valtimoplugins.slack.client +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.ritense.valtimoplugins.slack.domain.SlackMessage + +/** + * The envelope every Slack Web API method answers with. Note that a failure is reported in + * `ok`, with HTTP 200 — so the status code says nothing about whether the call worked. + */ +@JsonIgnoreProperties(ignoreUnknown = true) data class SlackResponse( val ok: Boolean, val error: String? = null, ) + +/** + * https://api.slack.com/methods/chat.postMessage + * + * [ts] is the reason this response is read rather than discarded: it is the id of the message + * just posted, and a case that wants an answer has to remember it to recognise the replies. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ChatPostMessageResponse( + val ok: Boolean, + val error: String? = null, + val channel: String? = null, + val ts: String? = null, +) + +/** + * https://api.slack.com/methods/conversations.history + * https://api.slack.com/methods/conversations.replies + * + * Both methods return the same shape, newest message first. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ConversationsResponse( + val ok: Boolean, + val error: String? = null, + val messages: List = emptyList(), + @JsonProperty("has_more") + val hasMore: Boolean = false, + @JsonProperty("response_metadata") + val responseMetadata: ResponseMetadata? = null, +) { + /** Absent once the last page has been served, which is the signal to stop paging. */ + val nextCursor: String? get() = responseMetadata?.nextCursor?.takeIf { it.isNotBlank() } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ResponseMetadata( + @JsonProperty("next_cursor") + val nextCursor: String? = null, +) + +/** + * A message as Slack serialises it. + * + * Deliberately a separate type from [SlackMessage]: the wire format is Slack's to change, and + * only the channel-plus-`ts` pair and a handful of fields matter to a process. The channel is + * not part of it because Slack does not repeat it per message — the caller knows which + * channel it asked about, and supplies it in [toSlackMessage]. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class SlackMessageResponse( + val type: String? = null, + val subtype: String? = null, + val ts: String? = null, + @JsonProperty("thread_ts") + val threadTs: String? = null, + val text: String? = null, + val user: String? = null, + val username: String? = null, + @JsonProperty("bot_id") + val botId: String? = null, + @JsonProperty("app_id") + val appId: String? = null, + val files: List = emptyList(), +) { + /** + * Returns `null` for anything that cannot be treated as a message, which the history of + * a real channel is full of: tombstones left by a deletion, and the `message_changed` + * events Slack emits when somebody edits an old message. Neither carries a usable `ts` of + * its own, and an edit is not a new message — resuming a case on one would let anybody + * re-trigger a process by editing a message from last year. + */ + fun toSlackMessage(channel: String): SlackMessage? { + if (ts.isNullOrBlank()) return null + if (type != null && type != MESSAGE_TYPE) return null + if (subtype in IGNORED_SUBTYPES) return null + + return SlackMessage( + channel = channel, + ts = ts, + threadTs = threadTs?.takeIf { it.isNotBlank() }, + text = text, + userId = user, + userName = username, + botId = botId, + appId = appId, + subtype = subtype, + fileNames = files.mapNotNull { it.name }, + ) + } + + private companion object { + private const val MESSAGE_TYPE = "message" + + /** + * Channel bookkeeping that is technically a message but is never a process trigger. + * + * `thread_broadcast` is deliberately absent: a reply sent with "also send to channel" + * carries that subtype and is otherwise an ordinary reply from a person, so dropping + * it would lose exactly the answer a case was waiting for. + */ + private val IGNORED_SUBTYPES = + setOf( + "message_changed", + "message_deleted", + "message_replied", + "channel_join", + "channel_leave", + "channel_topic", + "channel_purpose", + "channel_name", + "channel_archive", + "channel_unarchive", + "tombstone", + ) + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class SlackFileResponse( + val id: String? = null, + val name: String? = null, + val mimetype: String? = null, +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/ProcessedSlackMessage.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/ProcessedSlackMessage.kt new file mode 100644 index 0000000..a99587f --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/ProcessedSlackMessage.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.Instant + +/** + * Marker row proving a Slack message already started or resumed a process. + * + * The primary key is what makes this work on more than one node: two pods polling the same + * channel both fetch the message, both try to insert, and the loser gets a constraint + * violation instead of starting a second case. The insert is therefore flushed before the + * process is started, while staying in the same transaction as it, so that a process which + * fails to start also releases its claim — see + * `com.ritense.valtimoplugins.slack.service.IncomingSlackMessageHandler.handle`. + * + * It is also the second line of defence behind the channel cursor: a cursor that is re-read + * after a rollback, or a thread poll that overlaps the history poll, would otherwise offer + * the same message twice. + */ +@Entity +@Table(name = "slack_processed_message") +class ProcessedSlackMessage( + @Id + @Column(name = "message_identity") + val messageIdentity: String, + @Column(name = "plugin_configuration_id", nullable = false) + val pluginConfigurationId: String, + @Column(name = "channel", nullable = false) + val channel: String, + @Column(name = "message_ts", nullable = false) + val messageTs: String, + @Column(name = "processed_at", nullable = false) + val processedAt: Instant = Instant.now(), +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/ReceiveMessageProperties.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/ReceiveMessageProperties.kt new file mode 100644 index 0000000..ea39e4f --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/ReceiveMessageProperties.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.domain + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +/** + * Action properties of the `receive-message` process link: which channel to read, and which + * of its messages this particular link should start a process for. + * + * [channel] is the only one that is not a filter — it is what tells the poller there is a + * channel to read at all. The rest are optional and AND-ed, so a link with only a channel + * picks up every message posted in it, which is the common single-process setup. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ReceiveMessageProperties( + /** + * The channel id, like `C012AB3CD`. + * + * An id rather than a `#name`, because `conversations.history` only accepts ids: a name + * would have to be looked up first, and would silently stop resolving the day somebody + * renames the channel. + */ + val channel: String? = null, + val messageContains: String? = null, + /** Filters on the Slack user id of the author, like `U012AB3CD`. */ + val userId: String? = null, + /** + * Whether messages Slack attributes to an app should be handled too. Off unless set, + * because the case's own outgoing messages are app messages: a link that accepts them and + * a process that answers them form a loop that posts until the rate limit stops it. + */ + val includeBotMessages: Boolean? = null, + /** + * Whether to consider only replies inside a thread, or only messages that start one. + * + * Unset means both. Useful when one channel drives two links: a message start event that + * should only fire on new conversations ([THREAD_STARTS_ONLY]), and a catch event that + * should only see the replies to them ([THREAD_REPLIES_ONLY]). + */ + val threadScope: String? = null, +) { + fun matches(message: SlackMessage): Boolean = + matchesChannel(message) && + matchesBotOrigin(message) && + matchesThreadScope(message) && + (userId.isNullOrBlank() || userId.equals(message.userId, ignoreCase = true)) && + (messageContains.isNullOrBlank() || message.text?.contains(messageContains, ignoreCase = true) == true) + + /** + * A link with no channel matches nothing. + * + * It cannot be polled, so a message can only reach it via another link on the same + * configuration — and letting it match then would hand it messages from a channel its + * author never named. + */ + private fun matchesChannel(message: SlackMessage): Boolean = + !channel.isNullOrBlank() && channel.equals(message.channel, ignoreCase = true) + + private fun matchesBotOrigin(message: SlackMessage): Boolean = includeBotMessages == true || !message.isFromBot + + private fun matchesThreadScope(message: SlackMessage): Boolean = + when (threadScope?.takeIf { it.isNotBlank() }) { + null, ANY_MESSAGE -> true + THREAD_REPLIES_ONLY -> message.isThreadReply + THREAD_STARTS_ONLY -> !message.isThreadReply + else -> true + } + + companion object { + const val ANY_MESSAGE = "ANY" + const val THREAD_STARTS_ONLY = "THREAD_STARTS_ONLY" + const val THREAD_REPLIES_ONLY = "THREAD_REPLIES_ONLY" + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackChannelCursor.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackChannelCursor.kt new file mode 100644 index 0000000..5a7284f --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackChannelCursor.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.Instant + +/** + * How far one configuration has read one channel. + * + * A channel is not a mailbox: it is a shared, long-lived room that already contains + * everything ever said in it. Without a remembered position the first poll would read that + * history and start a case per message, and every later poll would re-read it and lean on + * the claim table to throw the results away. + * + * The row is created — and immediately committed — the first time a channel is seen, holding + * a timestamp derived from `initialLookback` rather than from the channel's contents. That is + * the deliberate asymmetry with the mail plugin: past messages are skipped, not queued. + */ +@Entity +@Table(name = "slack_channel_cursor") +class SlackChannelCursor( + /** `"|"`; see [idOf]. */ + @Id + @Column(name = "cursor_id") + val cursorId: String, + @Column(name = "plugin_configuration_id", nullable = false) + val pluginConfigurationId: String, + @Column(name = "channel", nullable = false) + val channel: String, + /** + * The `ts` of the newest message read so far, passed back to Slack as `oldest`. + * + * Only ever moves forward: [advanceTo] ignores an older value, so a page that arrives out + * of order, or a thread reply fetched alongside a newer top-level message, cannot rewind + * the channel and replay what has already been handled. + */ + @Column(name = "last_message_ts", nullable = false) + var lastMessageTs: String, + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = Instant.now(), +) { + /** Returns `true` when the cursor actually moved, so the caller can skip a pointless write. */ + fun advanceTo(ts: String?): Boolean { + if (ts == null || SlackMessage.compareTimestamps(ts, lastMessageTs) <= 0) { + return false + } + lastMessageTs = ts + updatedAt = Instant.now() + return true + } + + companion object { + fun idOf( + pluginConfigurationId: String, + channel: String, + ): String = "$pluginConfigurationId|$channel" + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackConnectionProperties.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackConnectionProperties.kt new file mode 100644 index 0000000..b1c337b --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackConnectionProperties.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.domain + +import java.net.URI +import java.time.Duration + +/** + * A resolved plugin configuration: where Slack is, which token to present, and how much of + * a channel one poll may read. + * + * Passed explicitly to every read call rather than held on the client, because the poller + * runs on a scheduler thread while process instances are posting messages on their own + * threads, and a shared mutable token on a singleton client would let the two overwrite each + * other's credentials mid-request. + */ +data class SlackConnectionProperties( + val baseUri: URI, + val token: String, + /** + * Page size for `conversations.history` and `conversations.replies`. Slack accepts up to + * 1000 but recommends staying well below it, because a large page is likelier to time out + * than to arrive. + */ + val messagesPerPage: Int, + /** + * Hard stop on how many pages one channel may consume per poll. Reaching it means the + * oldest part of the window is left unread and the cursor moves past it, which is why + * [com.ritense.valtimoplugins.slack.service.SlackMessagePollingService] logs a warning + * when it happens instead of failing quietly. + */ + val maxPagesPerPoll: Int, + /** + * Hard stop on how many open threads one channel may be asked about per poll. + * + * Slack charges a call per thread, and a token that runs out of calls stops reading the + * channel's history too, so an unbounded number of waiting cases would take the whole + * channel down with it. + */ + val maxThreadsPerPoll: Int, + /** + * How far back the very first poll of a channel looks. + * + * A channel, unlike a dedicated mailbox, normally has years of history that nobody wants + * turned into cases. So the first poll does not read the channel's past: it only sets the + * cursor this far back, and anything older is never fetched. + */ + val initialLookback: Duration, +) { + fun validate() { + require(baseUri.host != null) { "Slack URL '$baseUri' has no host" } + require(token.isNotBlank()) { "Slack token is blank" } + require(messagesPerPage in 1..MAX_MESSAGES_PER_PAGE) { + "Messages per page must be between 1 and $MAX_MESSAGES_PER_PAGE, but was $messagesPerPage" + } + require(maxPagesPerPoll >= 1) { "Maximum pages per poll must be at least 1, but was $maxPagesPerPoll" } + require(maxThreadsPerPoll >= 0) { "Maximum threads per poll must not be negative, but was $maxThreadsPerPoll" } + require(!initialLookback.isNegative) { "Initial lookback must not be negative, but was $initialLookback" } + } + + /** For log lines: never includes the token. */ + fun describe(): String = "Slack at ${baseUri.host}" + + companion object { + /** Slack's documented ceiling for the `limit` parameter of the conversations methods. */ + const val MAX_MESSAGES_PER_PAGE = 1000 + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackMessage.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackMessage.kt new file mode 100644 index 0000000..551ac30 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/domain/SlackMessage.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.domain + +import java.math.BigDecimal +import java.time.Instant + +/** + * One Slack message, read from a channel and reduced to what a process needs. + * + * The channel is carried along even though Slack does not return it inside a + * `conversations.history` message: the same `ts` can exist in two channels, so nothing about + * a message is unique until the channel is part of it. See [identity]. + */ +data class SlackMessage( + val channel: String, + /** + * Slack's message timestamp — `"1735689600.123456"`. Doubles as the message id within a + * channel, which is why it is kept as the string Slack sent rather than parsed into an + * [Instant]: it is passed back to the API verbatim as the `oldest` cursor, and a + * round-trip through a floating point number would not reproduce it exactly. + */ + val ts: String, + /** + * The `ts` of the message that opened the thread, present on every message in a thread — + * including the parent, where it equals [ts]. Absent on a message that is not part of a + * thread at all. + */ + val threadTs: String? = null, + val text: String? = null, + val userId: String? = null, + val userName: String? = null, + val botId: String? = null, + val appId: String? = null, + /** + * Slack's message subtype: absent on a plain message, and set on the dozens of variants + * that are really channel events (`channel_join`, `message_changed`, `bot_message`, ...). + */ + val subtype: String? = null, + val fileNames: List = emptyList(), +) { + /** Unique across the workspace, and the basis of the claim key. */ + val identity: String get() = "$channel/$ts" + + /** + * The thread this message belongs to, which for a message outside any thread is the + * message itself. + * + * This is what correlation runs on: a case remembers the conversation it is part of, and + * every later message in that thread reports the same value here. See + * [com.ritense.valtimoplugins.slack.service.SlackMessageProcessStarter]. + */ + val conversationTs: String get() = threadTs ?: ts + + val isThreadReply: Boolean get() = threadTs != null && threadTs != ts + + /** + * Whether Slack attributes this message to an app rather than a person. + * + * Load-bearing: the message this plugin's own `post-message` action writes comes back on + * the next poll of that channel, and treating it as incoming would start a case for + * every message the case itself sent. See [ReceiveMessageProperties.includeBotMessages]. + */ + val isFromBot: Boolean get() = botId != null || appId != null || subtype == BOT_MESSAGE_SUBTYPE + + /** Derived from [ts], which Slack defines as epoch seconds with microsecond precision. */ + val sentAt: Instant? + get() = + runCatching { + val seconds = BigDecimal(ts) + Instant.ofEpochSecond( + seconds.toLong(), + seconds.remainder(BigDecimal.ONE).movePointRight(NANOS_SCALE).toLong(), + ) + }.getOrNull() + + /** + * Process variables handed to the started or resumed instance. + * + * Prefixed with `slack` so they cannot collide with variables the case process already + * uses, and flat rather than nested because BPMN expressions and FormIO both deal poorly + * with nested maps. + */ + fun toProcessVariables(): Map = + buildMap { + put(CHANNEL_VARIABLE, channel) + put(MESSAGE_TS_VARIABLE, ts) + put(THREAD_TS_VARIABLE, conversationTs) + put("slackMessageIsThreadReply", isThreadReply) + put("slackMessageIsFromBot", isFromBot) + put("slackMessageFileNames", fileNames) + text?.let { put("slackMessageText", it) } + userId?.let { put("slackUserId", it) } + userName?.let { put("slackUserName", it) } + botId?.let { put("slackBotId", it) } + subtype?.let { put("slackMessageSubtype", it) } + sentAt?.let { put("slackMessageSentAt", it.toString()) } + } + + companion object { + /** + * The variable a case is correlated on. Written by [toProcessVariables] and by the + * `post-message` action, and read back when a reply arrives — a rename in one place + * only would silently stop every reply from finding its case. + */ + const val THREAD_TS_VARIABLE = "slackThreadTs" + + /** The channel a case is conversing in, so a reply can be posted back to it. */ + const val CHANNEL_VARIABLE = "slackChannel" + + /** The `ts` of the single message that started or resumed the case. */ + const val MESSAGE_TS_VARIABLE = "slackMessageTs" + + private const val BOT_MESSAGE_SUBTYPE = "bot_message" + + /** Scale of the fractional part of an epoch-seconds value, in nanoseconds. */ + private const val NANOS_SCALE = 9 + + /** + * Orders two Slack timestamps. + * + * Numeric rather than lexicographic: `"1735689600.1"` and `"1735689600.123456"` are + * the same instant to within a rounding error, but sort apart as strings, and Slack + * trims trailing zeroes on some payloads. Comparing the strings would eventually + * move a cursor backwards. + */ + fun compareTimestamps( + left: String, + right: String, + ): Int = + runCatching { BigDecimal(left).compareTo(BigDecimal(right)) } + .getOrElse { left.compareTo(right) } + + /** The later of two timestamps, treating a missing one as "no timestamp yet". */ + fun latestTimestamp( + left: String?, + right: String?, + ): String? = + when { + left == null -> right + right == null -> left + compareTimestamps(left, right) >= 0 -> left + else -> right + } + + /** + * The earlier of two read positions, where a missing one means "from the beginning" + * and therefore wins. + * + * The opposite convention to [latestTimestamp] on purpose: this one combines the + * positions of several readers of the same conversation, and reading from anywhere + * later than the one furthest behind would skip what it has not seen. + */ + fun earliestPosition( + left: String?, + right: String?, + ): String? = + when { + left == null || right == null -> null + compareTimestamps(left, right) <= 0 -> left + else -> right + } + + /** Formats an instant the way Slack's `oldest` parameter expects it. */ + fun timestampOf(instant: Instant): String = "%d.%06d".format(instant.epochSecond, instant.nano / 1_000) + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/plugin/SlackPlugin.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/plugin/SlackPlugin.kt index 19eaf37..5f4d1c5 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/plugin/SlackPlugin.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/plugin/SlackPlugin.kt @@ -21,16 +21,23 @@ import com.ritense.plugin.annotation.PluginAction import com.ritense.plugin.annotation.PluginActionProperty import com.ritense.plugin.annotation.PluginProperty import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ActivityTypeWithEventName.INTERMEDIATE_CATCH_EVENT_END +import com.ritense.processlink.domain.ActivityTypeWithEventName.MESSAGE_START_EVENT_START +import com.ritense.processlink.domain.ActivityTypeWithEventName.RECEIVE_TASK_END import com.ritense.resource.domain.MetadataType import com.ritense.resource.service.TemporaryResourceStorageService import com.ritense.valtimoplugins.slack.client.SlackClient +import com.ritense.valtimoplugins.slack.domain.SlackConnectionProperties +import com.ritense.valtimoplugins.slack.domain.SlackMessage +import io.github.oshai.kotlinlogging.KotlinLogging import org.operaton.bpm.engine.delegate.DelegateExecution import java.net.URI +import java.time.Duration @Plugin( key = "slack", title = "Slack Plugin", - description = "Post message with the Slack plugin", + description = "Post messages to Slack, and start or continue a process when Slack is posted to", ) open class SlackPlugin( private val slackClient: SlackClient, @@ -42,6 +49,23 @@ open class SlackPlugin( @PluginProperty(key = "token", secret = true) lateinit var token: String + /** Page size for reading a channel. Defaults to [DEFAULT_MESSAGES_PER_PAGE]. */ + @PluginProperty(key = "messagesPerPage", secret = false, required = false) + var messagesPerPage: Int? = null + + @PluginProperty(key = "maxPagesPerPoll", secret = false, required = false) + var maxPagesPerPoll: Int? = null + + @PluginProperty(key = "maxThreadsPerPoll", secret = false, required = false) + var maxThreadsPerPoll: Int? = null + + /** + * How many minutes of a channel's past the first poll considers. Everything older is + * never read — see [com.ritense.valtimoplugins.slack.domain.SlackChannelCursor]. + */ + @PluginProperty(key = "initialLookbackMinutes", secret = false, required = false) + var initialLookbackMinutes: Long? = null + @PluginAction( key = "post-message", title = "Post message", @@ -52,13 +76,26 @@ open class SlackPlugin( execution: DelegateExecution, @PluginActionProperty channel: String, @PluginActionProperty message: String, + @PluginActionProperty threadTs: String?, ) { - slackClient.baseUri = url - slackClient.token = token - slackClient.chatPostMessage( - channel = channel, - message = message, - ) + val response = + slackClient.chatPostMessage( + connection = connectionProperties(), + channel = channel, + message = message, + threadTs = threadTs?.takeIf { it.isNotBlank() }, + ) + + // Recorded so the process can be continued by the answers to this message: a reply + // reports the thread it belongs to, and `receive-message` matches that against these + // variables to find the one case that is waiting for it. Without this the case would + // have to be told its own thread by hand, and every reply would be a broadcast. + response.ts?.let { ts -> + execution.setVariable(SlackMessage.CHANNEL_VARIABLE, response.channel ?: channel) + execution.setVariable(SlackMessage.MESSAGE_TS_VARIABLE, ts) + // The thread of a reply is the message it answers; of a new message, itself. + execution.setVariable(SlackMessage.THREAD_TS_VARIABLE, threadTs?.takeIf { it.isNotBlank() } ?: ts) + } } @PluginAction( @@ -81,9 +118,8 @@ open class SlackPlugin( val contentAsInputStream = storageService.getResourceContentAsInputStream(resourceId) val metadata = storageService.getResourceMetadata(resourceId) - slackClient.baseUri = url - slackClient.token = token slackClient.filesUpload( + connection = connectionProperties(), channels = channels, message = message, fileName = fileName ?: metadata[MetadataType.FILE_NAME.key] as String, @@ -91,7 +127,78 @@ open class SlackPlugin( ) } + /** + * Marks a BPMN element as the entry point for messages from a Slack channel. + * + * The action does no work of its own: it is a marker, recording that this channel should + * start (or continue) that process, and + * [com.ritense.valtimoplugins.slack.service.SlackMessagePollingService] finds those + * markers when it polls. There is nothing to invoke synchronously, because a message + * arrives when somebody types it, not when the process engine asks. + * + * Supported on a message start event (starts a new case per message) and on a receive + * task or intermediate catch event (continues a case waiting for an answer in its + * thread). The optional properties are a filter, which is what lets one channel feed + * several processes: link each with a different filter and each sees only its own + * messages. + * + * The token needs `channels:history` — plus `groups:history`, `im:history` or + * `mpim:history` for the conversation types those cover — and the app has to be a member + * of the channel. + */ + @PluginAction( + key = "receive-message", + title = "Receive message", + description = "Start or continue a process for each message posted in a Slack channel", + activityTypes = [MESSAGE_START_EVENT_START, RECEIVE_TASK_END, INTERMEDIATE_CATCH_EVENT_END], + ) + fun receiveMessage( + @PluginActionProperty channel: String, + @PluginActionProperty messageContains: String?, + @PluginActionProperty userId: String?, + @PluginActionProperty includeBotMessages: Boolean?, + @PluginActionProperty threadScope: String?, + ) { + // Never invoked by the engine - see the documentation above. Logged rather than left + // empty so that a call, which would mean the marker is wired up as something the + // engine executes, is visible instead of silent. + logger.debug { + "receive-message marker reached for channel '$channel' (text='$messageContains', user='$userId', " + + "bots=$includeBotMessages, threads='$threadScope')" + } + } + + /** + * Resolves the configuration into the value object the client and poller work with, + * applying defaults. + * + * Defaults live here rather than in the frontend so that a configuration created through + * the API behaves identically to one created in the admin UI. + */ + fun connectionProperties(): SlackConnectionProperties = + SlackConnectionProperties( + baseUri = url, + token = token, + messagesPerPage = messagesPerPage ?: DEFAULT_MESSAGES_PER_PAGE, + maxPagesPerPoll = maxPagesPerPoll ?: DEFAULT_MAX_PAGES_PER_POLL, + maxThreadsPerPoll = maxThreadsPerPoll ?: DEFAULT_MAX_THREADS_PER_POLL, + initialLookback = Duration.ofMinutes(initialLookbackMinutes ?: DEFAULT_INITIAL_LOOKBACK_MINUTES), + ).also { it.validate() } + companion object { + private val logger = KotlinLogging.logger {} + + const val PLUGIN_KEY = "slack" const val RESOURCE_ID_PROCESS_VAR = "resourceId" + + private const val DEFAULT_MESSAGES_PER_PAGE = 100 + private const val DEFAULT_MAX_PAGES_PER_POLL = 10 + private const val DEFAULT_MAX_THREADS_PER_POLL = 50 + + /** + * Long enough that a channel configured just after a message was posted still picks + * it up, short enough that it cannot drag in a working day of unrelated chatter. + */ + private const val DEFAULT_INITIAL_LOOKBACK_MINUTES = 15L } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/repository/ProcessedSlackMessageRepository.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/repository/ProcessedSlackMessageRepository.kt new file mode 100644 index 0000000..bc1e8dc --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/repository/ProcessedSlackMessageRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.repository + +import com.ritense.valtimoplugins.slack.domain.ProcessedSlackMessage +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.Instant + +interface ProcessedSlackMessageRepository : JpaRepository { + @Modifying + @Query("delete from ProcessedSlackMessage p where p.processedAt < :before") + fun deleteProcessedBefore( + @Param("before") before: Instant, + ): Int +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/repository/SlackChannelCursorRepository.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/repository/SlackChannelCursorRepository.kt new file mode 100644 index 0000000..f426cea --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/repository/SlackChannelCursorRepository.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.repository + +import com.ritense.valtimoplugins.slack.domain.SlackChannelCursor +import org.springframework.data.jpa.repository.JpaRepository + +interface SlackChannelCursorRepository : JpaRepository diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/IncomingSlackMessageHandler.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/IncomingSlackMessageHandler.kt new file mode 100644 index 0000000..c5eb77b --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/IncomingSlackMessageHandler.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.authorization.annotation.RunWithoutAuthorization +import com.ritense.plugin.domain.PluginProcessLink +import com.ritense.valtimoplugins.slack.domain.ProcessedSlackMessage +import com.ritense.valtimoplugins.slack.domain.ReceiveMessageProperties +import com.ritense.valtimoplugins.slack.domain.SlackMessage +import com.ritense.valtimoplugins.slack.repository.ProcessedSlackMessageRepository +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** + * Handles one incoming Slack message in one transaction: claim it, then start the process. + * + * A separate bean from [SlackMessagePollingService] rather than a method on it, because the + * transaction and authorization advice are applied by a proxy — a call from the polling loop + * to a method on its own instance would silently bypass both, and every message would be + * claimed and started outside a transaction. + * + * Both steps share one transaction, so a process that fails to start also releases the claim + * and the message is retried on the next poll. Committing the claim first would be the other + * trade-off: no retry, and a message lost to a transient failure stays lost. + */ +open class IncomingSlackMessageHandler( + private val processedSlackMessageRepository: ProcessedSlackMessageRepository, + private val slackMessageProcessStarter: SlackMessageProcessStarter, + private val objectMapper: ObjectMapper, +) { + /** + * Returns `true` when a process was started or resumed, `false` when nothing matched. + * + * Throws when the message has already been handled — the primary key on + * `slack_processed_message` is what makes two nodes polling the same channel safe, and + * the violation has to escape this method for the transaction to roll back cleanly. The + * caller is expected to treat it as a duplicate rather than a failure. + */ + @RunWithoutAuthorization + @Transactional(propagation = Propagation.REQUIRES_NEW) + open fun handle( + message: SlackMessage, + pluginConfigurationId: String, + processLinks: List, + ): Boolean { + val key = claimKey(pluginConfigurationId, message.identity) + + if (processedSlackMessageRepository.existsById(key)) { + logger.debug { + "Message '${message.identity}' was already handled for configuration '$pluginConfigurationId'; skipping" + } + return false + } + + // Flushed rather than left to commit so that a concurrent claim from another node + // fails here, before a case is created. + processedSlackMessageRepository.saveAndFlush( + ProcessedSlackMessage( + messageIdentity = key, + pluginConfigurationId = pluginConfigurationId, + channel = message.channel, + messageTs = message.ts, + ), + ) + + val matching = processLinks.filter { matches(it, message) } + + if (matching.isEmpty()) { + logger.debug { + "Message '${message.identity}' from '${message.userId ?: message.botId}' matched no " + + "receive-message process link" + } + return false + } + + // A started instance for any link is enough to call the message handled. The links + // are independent: one may be a message start event that always fires, another a + // catch event that only fires when something is waiting for it. + val started = matching.map { slackMessageProcessStarter.start(it, message) }.any { it } + + if (started) { + logger.info { + "Started ${matching.size} process link(s) for Slack message '${message.identity}' " + + "from '${message.userId ?: message.botId}'" + } + } else { + logger.debug { + "Message '${message.identity}' matched ${matching.size} process link(s), none of which had " + + "anything to start" + } + } + return started + } + + private fun matches( + processLink: PluginProcessLink, + message: SlackMessage, + ): Boolean { + // Unlike the mail plugin, a link without properties matches nothing: the channel to + // read lives in those properties, so their absence means the link names no channel. + val properties = processLink.actionProperties ?: return false + val filter = + try { + objectMapper.treeToValue(properties, ReceiveMessageProperties::class.java) + } catch (e: Exception) { + logger.warn(e) { + "Could not read the filter of the receive-message link on activity '${processLink.activityId}' " + + "of process definition '${processLink.processDefinitionId}'; ignoring the link" + } + return false + } + return filter.matches(message) + } + + private fun claimKey( + pluginConfigurationId: String, + identity: String, + ): String { + // Scoped per configuration: two configurations may legitimately watch the same + // channel on behalf of different processes, and each should get its own case. + val key = "$pluginConfigurationId|$identity" + if (key.length <= MAX_IDENTITY_LENGTH) return key + + // Truncating would map two long, distinct identities onto one key and silently drop + // the second message as a duplicate. A digest keeps them distinct. + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(key.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + return "$pluginConfigurationId|sha256:$digest" + } + + private companion object { + private val logger = KotlinLogging.logger {} + + /** Matches the `message_identity` column width. */ + private const val MAX_IDENTITY_LENGTH = 512 + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessagePollingService.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessagePollingService.kt new file mode 100644 index 0000000..5d80241 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessagePollingService.kt @@ -0,0 +1,442 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.plugin.domain.PluginConfigurationId +import com.ritense.plugin.domain.PluginProcessLink +import com.ritense.plugin.service.PluginService +import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository +import com.ritense.valtimoplugins.slack.client.SlackClient +import com.ritense.valtimoplugins.slack.domain.ReceiveMessageProperties +import com.ritense.valtimoplugins.slack.domain.SlackChannelCursor +import com.ritense.valtimoplugins.slack.domain.SlackConnectionProperties +import com.ritense.valtimoplugins.slack.domain.SlackMessage +import com.ritense.valtimoplugins.slack.plugin.SlackPlugin +import com.ritense.valtimoplugins.slack.repository.ProcessedSlackMessageRepository +import com.ritense.valtimoplugins.slack.repository.SlackChannelCursorRepository +import io.github.oshai.kotlinlogging.KotlinLogging +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Drives the inbound side of the plugin: on a schedule, reads every channel some BPMN model + * is waiting on and feeds the messages it finds to [IncomingSlackMessageHandler]. + * + * Which channels those are is derived from the `receive-message` process links rather than + * from the plugin configurations, so a configuration nobody links to costs nothing: adding + * one for a workspace you are not ready to process yet is harmless, and removing the last + * link stops the reading without anyone having to remember to delete the token. + * + * Two things a mailbox does not require, and a channel does: + * - a per-channel cursor, because a channel is full of history nobody wants turned into + * cases. See [SlackChannelCursor]. + * - a second read per open conversation, because Slack keeps thread replies out of a + * channel's history. The engine says which threads are still unanswered, and only those + * are asked about. See [SlackMessageProcessStarter.threadsAwaitingReply]. + */ +open class SlackMessagePollingService( + private val pluginProcessLinkRepository: ValtimoPluginProcessLinkRepository, + private val pluginService: PluginService, + private val slackClient: SlackClient, + private val incomingSlackMessageHandler: IncomingSlackMessageHandler, + private val slackMessageProcessStarter: SlackMessageProcessStarter, + private val processedSlackMessageRepository: ProcessedSlackMessageRepository, + private val slackChannelCursorRepository: SlackChannelCursorRepository, + private val objectMapper: ObjectMapper, + private val retentionDays: Long, +) { + /** + * Guards against overlapping runs *within this JVM*. + * + * Spring's default scheduler is single-threaded, so today this cannot happen — but a + * workspace with many channels can easily outlast a five-minute interval, especially on + * a token that is being rate limited, and the day someone configures a pool the overlap + * would double-fetch every message and rely entirely on the claim table to sort it out. + * + * The `@SchedulerLock` below is the other half: this flag says nothing about the other + * nodes of a cluster, all of which run the same cron against the same channels. + */ + private val running = AtomicBoolean(false) + + /** + * `lockAtMostFor` is the deadline for a node that dies mid-poll: until it passes, no + * other node takes over. Generous relative to the default five-minute interval, because + * the cost of releasing early — two nodes in the same channel — is worse than the cost of + * a late release, which is a few skipped polls. + */ + @Scheduled(cron = "\${valtimo.slack.poll-cron:0 */5 * * * *}") + @SchedulerLock(name = "slackPollChannels", lockAtLeastFor = "PT1S", lockAtMostFor = "PT10M") + open fun pollChannels() { + if (!running.compareAndSet(false, true)) { + logger.info { "Skipping this Slack poll: the previous one is still running" } + return + } + try { + pollAllConfigurations() + } finally { + running.set(false) + } + } + + private fun pollAllConfigurations() { + val links = pluginProcessLinkRepository.findByPluginActionDefinitionKey(RECEIVE_MESSAGE_ACTION) + + // A process link may name its plugin configuration indirectly, through a reference + // resolved at execution time from process variables. That cannot work here: there is + // no execution to resolve against until a message has already been read, which is the + // very thing the configuration is needed for. Skip those loudly. + val (bound, unbound) = links.partition { it.pluginConfigurationId != null } + unbound.forEach { + logger.warn { + "Ignoring the receive-message link on activity '${it.activityId}' of process definition " + + "'${it.processDefinitionId}': it has no fixed plugin configuration, and a workspace cannot be " + + "resolved from process variables before the message is read." + } + } + + val linksByConfiguration = bound.groupBy { requireNotNull(it.pluginConfigurationId) } + + if (linksByConfiguration.isEmpty()) { + logger.debug { "No '$RECEIVE_MESSAGE_ACTION' process links configured; nothing to poll" } + return + } + + logger.debug { "Polling ${linksByConfiguration.size} Slack configuration(s)" } + linksByConfiguration.forEach { (configurationId, processLinks) -> + // One workspace failing must not stop the others: a single revoked token would + // otherwise silently stall every other workspace in the installation. + try { + pollConfiguration(configurationId, processLinks) + } catch (e: Exception) { + logger.error(e) { "Polling Slack configuration '$configurationId' failed" } + } + } + } + + /** + * `open` on purpose, even though nothing overrides it: the bean is proxied for + * `@Scheduled` and `@SchedulerLock`, and a final method cannot be intercepted — a call + * arriving through the proxy would then run against the proxy's own uninitialised fields + * and fail with a null collaborator. + */ + internal open fun pollConfiguration( + configurationId: PluginConfigurationId, + processLinks: List, + ) { + val plugin = + runCatching { pluginService.createInstance(configurationId.id) } + .getOrElse { e -> + logger.warn(e) { "Plugin configuration '$configurationId' could not be instantiated; skipping" } + return + } + + val connection = plugin.connectionProperties() + val configurationKey = configurationId.id.toString() + + // A link names its channel in its own action properties, so one configuration can + // feed several channels and one channel several processes. Grouping by channel is + // what keeps that from turning into one Slack call per link. + val linksByChannel = + processLinks + .groupBy { channelOf(it) } + .filterKeys { it != null } + .mapKeys { requireNotNull(it.key) } + + if (linksByChannel.isEmpty()) { + logger.debug { "No readable channel on any receive-message link of configuration '$configurationId'" } + return + } + + linksByChannel.forEach { (channel, channelLinks) -> + try { + pollChannel(connection, configurationKey, channel, channelLinks) + } catch (e: Exception) { + logger.error(e) { "Polling channel '$channel' of configuration '$configurationId' failed" } + } + } + } + + private fun pollChannel( + connection: SlackConnectionProperties, + configurationKey: String, + channel: String, + processLinks: List, + ) { + val cursor = cursorFor(connection, configurationKey, channel) + val readFrom = cursor.lastMessageTs + + val history = slackClient.conversationsHistory(connection, channel, readFrom) + if (history.truncated) { + logger.warn { + "Read ${connection.maxPagesPerPoll} page(s) of channel '$channel' and Slack still had more to " + + "give. The cursor moves to the newest message read, so anything between '$readFrom' and " + + "'${history.messages.firstOrNull()?.ts}' that did not fit is not handled. Raise the page " + + "budget on the plugin configuration if this repeats." + } + } + + // The cursor moves over the channel's own messages and nothing else. + // + // Letting a thread reply move it would lose messages: the two reads are separate HTTP + // calls, and a message posted to the channel between them can carry a timestamp older + // than a reply fetched by the second call. Advancing to the newest of both would put + // the cursor past a message that was never read, and the next poll would not ask for + // it again. Replies need no cursor of their own — each waiting case says where it got + // to. See [pollAwaitedThreads]. + val handledHistory = handleInOrder(history.messages, configurationKey, processLinks) + advance(cursor, handledHistory.resumeFrom) + + // After the history, so that a case which started and parked earlier in this very poll + // is already counted among the conversations worth asking about. + val replies = pollAwaitedThreads(connection, channel, processLinks) + val handledReplies = handleInOrder(replies, configurationKey, processLinks) + + val read = history.messages.size + replies.size + // A quiet channel is the normal case, and logging it every five minutes for every + // channel would bury the polls that did something. + if (read == 0) { + return + } + + logger.info { + "Polled channel '$channel': $read read (${replies.size} from threads), " + + "${handledHistory.handled + handledReplies.handled} started, " + + "${handledHistory.skipped + handledReplies.skipped} skipped, " + + "${handledHistory.failed + handledReplies.failed} failed" + } + } + + /** + * Reads the threads that cases are waiting for an answer in. + * + * Bounded by the number of open conversations rather than by the size of the channel, + * because Slack charges one call per thread and a workspace on a modern app token is + * allowed very few calls per minute. The cap is a backstop for the case where a process + * leaks waiting instances; hitting it is reported rather than absorbed. + */ + private fun pollAwaitedThreads( + connection: SlackConnectionProperties, + channel: String, + processLinks: List, + ): List { + // Each thread is read from the position of the case that is furthest behind in it, so + // two cases waiting in one thread cost one call and neither misses its answer. + val threads = mutableMapOf() + processLinks.forEach { processLink -> + slackMessageProcessStarter.threadsAwaitingReply(processLink).forEach { (thread, seen) -> + threads[thread] = + if (threads.containsKey(thread)) { + SlackMessage.earliestPosition(threads[thread], seen) + } else { + seen + } + } + } + + if (threads.isEmpty()) { + return emptyList() + } + + // Furthest behind first, so the budget is a queue rather than a cliff. Taking them in + // map order instead would drop the same threads every poll, and a case whose thread + // never gets read is a case that never continues — quietly, and for good. + val budgeted = + threads.entries + .sortedWith( + compareBy(nullsFirst(Comparator(SlackMessage::compareTimestamps))) { it.value }, + ).take(connection.maxThreadsPerPoll) + + if (budgeted.size < threads.size) { + logger.warn { + "${threads.size} open conversation(s) in channel '$channel' are waiting for a reply, which is more " + + "than the configured ${connection.maxThreadsPerPoll} per poll. The remaining " + + "${threads.size - budgeted.size} are read on a later round, the ones furthest behind first." + } + } + + return budgeted.flatMap { (threadTs, seen) -> + try { + slackClient.conversationsReplies(connection, channel, threadTs, seen).messages + } catch (e: Exception) { + // A thread whose parent was deleted answers with an error forever. Losing it + // must not cost the other threads, nor the channel's own history. + logger.warn(e) { "Could not read thread '$threadTs' of channel '$channel'" } + emptyList() + } + } + } + + private fun handleInOrder( + messages: List, + configurationKey: String, + processLinks: List, + ): PollOutcome { + var handled = 0 + var skipped = 0 + var failed = 0 + var resumeFrom: String? = null + + // Once something has failed the cursor may not move any further, even over messages + // after it that succeed: it is a single position, and moving it past the failure is + // what would drop that message for good. + var blocked = false + + for (message in messages) { + try { + // Established here, outside the handler's transaction, even though + // IncomingSlackMessageHandler.handle is already @RunWithoutAuthorization. + // + // That annotation is not enough on its own: its aspect declares no order, so + // it runs inside the transaction advice and resets its thread-local before + // the commit. Valtimo's task listeners fire on AFTER_COMMIT, and they read + // the document to push an SSE update - a permission check that a scheduler + // thread, having no authenticated user, cannot pass. Wrapping the call keeps + // the context open across the commit. The thread-local is nesting-safe, so + // the inner annotation stays harmless. + val started = + runWithoutAuthorization { + incomingSlackMessageHandler.handle(message, configurationKey, processLinks) + } + if (started) handled++ else skipped++ + } catch (e: DataIntegrityViolationException) { + // Another node claimed this message first. Not an error, and not a reason to + // hold the cursor back - the node that won the race is handling it. + logger.debug(e) { "Message '${message.identity}' was claimed concurrently; skipping" } + skipped++ + } catch (e: Exception) { + failed++ + blocked = true + logger.error(e) { + "Failed to handle Slack message '${message.identity}'; it will be offered again on the next poll" + } + // Deliberately no `break`: the rest of this round is still attempted, so one + // message the process engine chokes on does not hold up the messages behind + // it. They keep their claim, so being offered again next poll costs a lookup + // and nothing more. + continue + } + if (!blocked) { + resumeFrom = SlackMessage.latestTimestamp(resumeFrom, message.ts) + } + } + + return PollOutcome(handled = handled, skipped = skipped, failed = failed, resumeFrom = resumeFrom) + } + + /** + * Loads the channel's cursor, creating it on first sight. + * + * The new cursor is committed before anything is read, so a poll that dies immediately + * afterwards still cannot mistake the channel's history for new messages on the next run. + */ + private fun cursorFor( + connection: SlackConnectionProperties, + configurationKey: String, + channel: String, + ): SlackChannelCursor { + val cursorId = SlackChannelCursor.idOf(configurationKey, channel) + slackChannelCursorRepository.findById(cursorId).orElse(null)?.let { return it } + + val startingPoint = Instant.now().minus(connection.initialLookback) + logger.info { + "First poll of channel '$channel' for configuration '$configurationKey'; starting at $startingPoint " + + "and leaving everything posted before that alone" + } + return slackChannelCursorRepository.save( + SlackChannelCursor( + cursorId = cursorId, + pluginConfigurationId = configurationKey, + channel = channel, + lastMessageTs = SlackMessage.timestampOf(startingPoint), + ), + ) + } + + private fun advance( + cursor: SlackChannelCursor, + ts: String?, + ) { + if (cursor.advanceTo(ts)) { + slackChannelCursorRepository.save(cursor) + } + } + + private fun channelOf(processLink: PluginProcessLink): String? { + val properties = processLink.actionProperties ?: return null + val channel = + try { + objectMapper.treeToValue(properties, ReceiveMessageProperties::class.java).channel + } catch (e: Exception) { + logger.warn(e) { + "Could not read the properties of the receive-message link on activity " + + "'${processLink.activityId}' of process definition '${processLink.processDefinitionId}'" + } + null + } + + if (channel.isNullOrBlank()) { + logger.warn { + "Ignoring the receive-message link on activity '${processLink.activityId}' of process definition " + + "'${processLink.processDefinitionId}': it names no channel, and there is nothing to read " + + "without one." + } + return null + } + return channel + } + + /** + * Prunes claim rows no channel can still produce. + * + * Without this the table grows for the lifetime of the installation. The channel cursors + * are left alone: they are one row per channel, and deleting one would send that channel + * back to reading from `initialLookback` — which, unlike a stale claim row, is a + * behaviour change rather than a cleanup. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @Scheduled(cron = "\${valtimo.slack.retention-cron:0 30 3 * * *}") + @SchedulerLock(name = "slackPruneProcessedMessages", lockAtLeastFor = "PT5S", lockAtMostFor = "PT60M") + open fun pruneProcessedMessages() { + val before = Instant.now().minus(retentionDays, ChronoUnit.DAYS) + val deleted = processedSlackMessageRepository.deleteProcessedBefore(before) + if (deleted > 0) { + logger.info { "Pruned $deleted processed Slack message row(s) older than $retentionDays day(s)" } + } + } + + private data class PollOutcome( + val handled: Int, + val skipped: Int, + val failed: Int, + /** How far the cursor may safely move: the newest message before the first failure. */ + val resumeFrom: String?, + ) + + companion object { + private val logger = KotlinLogging.logger {} + + const val RECEIVE_MESSAGE_ACTION = "receive-message" + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarter.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarter.kt new file mode 100644 index 0000000..c265a3f --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarter.kt @@ -0,0 +1,313 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.service + +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.ritense.case.service.CaseDefinitionService +import com.ritense.document.domain.impl.request.NewDocumentRequest +import com.ritense.plugin.domain.PluginProcessLink +import com.ritense.processdocument.domain.ProcessDefinitionId +import com.ritense.processdocument.domain.impl.request.NewDocumentAndStartProcessRequest +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.service.ProcessPropertyService +import com.ritense.valtimoplugins.slack.domain.SlackMessage +import io.github.oshai.kotlinlogging.KotlinLogging +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.engine.RuntimeService +import org.operaton.bpm.engine.runtime.Execution +import org.operaton.bpm.model.bpmn.instance.CatchEvent +import org.operaton.bpm.model.bpmn.instance.MessageEventDefinition +import org.springframework.stereotype.Service + +/** + * Starts, or resumes, the process behind a `receive-message` process link. + * + * Which of the two happens is decided by the BPMN element the link is attached to, not by + * configuration: + * - a message start event starts something new — a case (document process) for a normal + * process definition, or a bare process instance for a system process; + * - a receive task or intermediate catch event resumes an instance that is already waiting, + * which is how a reply in a Slack thread lands back in the case that opened it — and only + * in that case, see [signalWaitingExecutions]. + * + * Split out of the poller so that "which BPMN construct does this link mean" stays testable + * without a Slack workspace in the picture. + */ +@SkipComponentScan +@Service +class SlackMessageProcessStarter( + private val runtimeService: RuntimeService, + private val repositoryService: RepositoryService, + private val processPropertyService: ProcessPropertyService, + private val processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, + private val processDocumentService: ProcessDocumentService, + private val caseDefinitionService: CaseDefinitionService, +) { + /** + * Returns `true` when the link led to a started or resumed instance. + * + * A `false` means the link matched but had nothing to act on — typically a catch event + * with no instance waiting at it, or a reply that belongs to no open case. That is a + * normal state, not an error, so the caller counts it as skipped rather than failed. + */ + fun start( + processLink: PluginProcessLink, + message: SlackMessage, + ): Boolean { + val variables = message.toProcessVariables() + return when (processLink.activityType) { + ActivityTypeWithEventName.MESSAGE_START_EVENT_START -> startProcessByMessage(processLink, variables) + else -> signalWaitingExecutions(processLink, message, variables) + } + } + + /** + * The threads instances parked at this link are waiting in, each with the timestamp that + * instance has already seen. + * + * The poller needs this because Slack does not surface a thread reply in a channel's + * history: the only way to see one is to ask for that specific thread. Rather than track + * every thread the workspace ever opened, the question is turned around — the process + * engine already knows which conversations are unfinished, so those are the threads worth + * a call. + * + * The timestamp comes from the instance rather than from the channel's read position, and + * that distinction is load-bearing. A case does not necessarily park at its receive task + * in the same poll that started it — an asynchronous continuation can delay it — and in + * the meantime the channel cursor moves on past any newer message. Reading the thread + * from the channel's position would then skip the answer this case was waiting for, and + * skipping an answer means a case that never continues. Asking from where *the case* got + * to cannot miss it. + * + * A `null` value means the instance has no recorded position, so the thread is read from + * its beginning; the claim table removes what was already handled. + */ + fun threadsAwaitingReply(processLink: PluginProcessLink): Map { + if (processLink.activityType == ActivityTypeWithEventName.MESSAGE_START_EVENT_START) { + return emptyMap() + } + + val awaited = mutableMapOf() + waitingExecutions(processLink).forEach { execution -> + val thread = variableOf(execution, SlackMessage.THREAD_TS_VARIABLE) ?: return@forEach + val seen = variableOf(execution, SlackMessage.MESSAGE_TS_VARIABLE) + awaited[thread] = + if (awaited.containsKey(thread)) SlackMessage.earliestPosition(awaited[thread], seen) else seen + } + return awaited + } + + private fun startProcessByMessage( + processLink: PluginProcessLink, + variables: Map, + ): Boolean = + if (processPropertyService.isSystemProcessById(processLink.processDefinitionId)) { + startSystemProcessByMessage(processLink, variables) + } else { + startDocumentProcessByMessage(processLink, variables) + } + + private fun startSystemProcessByMessage( + processLink: PluginProcessLink, + variables: Map, + ): Boolean { + val messageName = messageNameOf(processLink) + logger.info { + "Starting system process by message '$messageName' for process definition '${processLink.processDefinitionId}'" + } + runtimeService + .createMessageCorrelation(messageName) + .processDefinitionId(processLink.processDefinitionId) + .setVariables(variables) + .correlateStartMessage() + return true + } + + private fun startDocumentProcessByMessage( + processLink: PluginProcessLink, + variables: Map, + ): Boolean { + val processDefinitionCaseDefinition = + try { + processDefinitionCaseDefinitionService + .findByProcessDefinitionId(ProcessDefinitionId(processLink.processDefinitionId)) + ?: return false + } catch (e: Exception) { + logger.warn(e) { + "No case definition linked to process definition '${processLink.processDefinitionId}'" + } + return false + } + + // Only the deployed, active version of a case definition may be started. Without + // this an old process link would keep creating cases against a superseded version. + val activeCaseDefinition = + caseDefinitionService.getActiveCaseDefinition(processDefinitionCaseDefinition.id.caseDefinitionId.key) + if (activeCaseDefinition?.id != processDefinitionCaseDefinition.id.caseDefinitionId) { + logger.debug { + "Skipping process link for '${processLink.processDefinitionId}': it points at a case definition " + + "version that is no longer active" + } + return false + } + + require(processDefinitionCaseDefinition.canInitializeDocument) { + "Cannot start a case for process definition '${processLink.processDefinitionId}' because " + + "canInitializeDocument is false on the linked case definition." + } + + val processDefinitionKey = + processDefinitionCaseDefinition.processDefinitionKey + ?: error("No process definition key found for '${processLink.processDefinitionId}'") + + val request = + NewDocumentAndStartProcessRequest( + processDefinitionKey, + NewDocumentRequest( + activeCaseDefinition.id.key, + activeCaseDefinition.id.key, + activeCaseDefinition.id.versionTag.toString(), + JsonNodeFactory.instance.objectNode(), + ), + ).withProcessVars(variables) + + logger.info { + "Creating a case for case definition '${activeCaseDefinition.id.key}' " + + "(${activeCaseDefinition.id.versionTag}) with process '$processDefinitionKey'" + } + val result = processDocumentService.newDocumentAndStartProcess(request) + if (result.errors().isNotEmpty()) { + error("Failed to create a case for the incoming Slack message: ${result.errors()}") + } + return true + } + + /** + * Resumes the instances this message is an answer to — and only those. + * + * The query finds every instance parked at the activity, across all cases, so it cannot + * be the answer on its own: signalling all of them would file one citizen's reply into + * every other case waiting at the same step. The thread is what narrows it down, so a + * message belonging to no thread a case is waiting on is left alone rather than delivered + * to everybody. + */ + private fun signalWaitingExecutions( + processLink: PluginProcessLink, + message: SlackMessage, + variables: Map, + ): Boolean { + val waiting = waitingExecutions(processLink) + + if (waiting.isEmpty()) { + logger.debug { + "No execution waiting at activity '${processLink.activityId}' of process definition " + + "'${processLink.processDefinitionId}'" + } + return false + } + + val executions = waiting.filter { answers(it, message) } + if (executions.isEmpty()) { + logger.debug { + "Message '${message.identity}' matched the filter on activity '${processLink.activityId}' but " + + "belongs to no thread any of the ${waiting.size} execution(s) waiting there is expecting; " + + "leaving them untouched" + } + return false + } + + executions.forEach { execution -> + logger.info { + "Resuming execution '${execution.id}' of process instance '${execution.processInstanceId}' " + + "at activity '${processLink.activityId}'" + } + when (processLink.activityType) { + ActivityTypeWithEventName.RECEIVE_TASK_END -> + runtimeService.signal(execution.id, variables) + + ActivityTypeWithEventName.INTERMEDIATE_CATCH_EVENT_END -> + runtimeService.messageEventReceived(messageNameOf(processLink), execution.id, variables) + + else -> + error("Unsupported activity type '${processLink.activityType}' for a receive-message process link") + } + } + return true + } + + private fun waitingExecutions(processLink: PluginProcessLink): List = + runtimeService + .createExecutionQuery() + .processDefinitionId(processLink.processDefinitionId) + .activityId(processLink.activityId) + .list() + + /** + * Decides whether this message continues the conversation that instance is waiting on. + * + * The instance remembers the thread it is part of in [SlackMessage.THREAD_TS_VARIABLE] — + * written either by the `post-message` action that opened the thread, or by the message + * that started the case. Every later message in that thread reports the same value, so + * matching the two routes an answer back to the one case that asked the question. + */ + private fun answers( + execution: Execution, + message: SlackMessage, + ): Boolean { + val awaited = variableOf(execution, SlackMessage.THREAD_TS_VARIABLE) ?: return false + return SlackMessage.compareTimestamps(awaited, message.conversationTs) == 0 + } + + private fun variableOf( + execution: Execution, + name: String, + ): String? = + runCatching { + runtimeService.getVariable(execution.processInstanceId, name) + }.getOrElse { e -> + logger.warn(e) { + "Could not read '$name' from process instance '${execution.processInstanceId}'" + } + null + }?.toString() + ?.takeIf { it.isNotBlank() } + + private fun messageNameOf(processLink: PluginProcessLink): String { + val model = repositoryService.getBpmnModelInstance(processLink.processDefinitionId) + val element = + model.getModelElementById(processLink.activityId) + ?: error( + "No catch event '${processLink.activityId}' in process definition '${processLink.processDefinitionId}'", + ) + return element.eventDefinitions + .filterIsInstance() + .firstOrNull() + ?.message + ?.name + ?: error( + "No message event definition on element '${processLink.activityId}' in process definition " + + "'${processLink.processDefinitionId}'", + ) + } + + private companion object { + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/plugin/src/main/resources/config/liquibase/changelog/20260910-create-slack-receive-message-tables.xml b/backend/plugin/src/main/resources/config/liquibase/changelog/20260910-create-slack-receive-message-tables.xml new file mode 100644 index 0000000..3e2f1d5 --- /dev/null +++ b/backend/plugin/src/main/resources/config/liquibase/changelog/20260910-create-slack-receive-message-tables.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/plugin/src/main/resources/config/liquibase/slack-master.xml b/backend/plugin/src/main/resources/config/liquibase/slack-master.xml new file mode 100644 index 0000000..0b7737c --- /dev/null +++ b/backend/plugin/src/main/resources/config/liquibase/slack-master.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/SlackReceiveMessageIT.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/SlackReceiveMessageIT.kt new file mode 100644 index 0000000..13383b1 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/SlackReceiveMessageIT.kt @@ -0,0 +1,369 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.document.domain.impl.request.NewDocumentRequest +import com.ritense.plugin.domain.PluginConfiguration +import com.ritense.plugin.service.PluginService +import com.ritense.plugin.web.rest.request.PluginProcessLinkCreateDto +import com.ritense.processdocument.domain.impl.request.NewDocumentAndStartProcessRequest +import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processlink.domain.ActivityTypeWithEventName.INTERMEDIATE_CATCH_EVENT_END +import com.ritense.processlink.domain.ActivityTypeWithEventName.MESSAGE_START_EVENT_START +import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository +import com.ritense.valtimo.contract.json.MapperSingleton +import com.ritense.valtimoplugins.slack.domain.SlackMessage +import com.ritense.valtimoplugins.slack.service.SlackMessagePollingService +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.engine.RuntimeService +import org.springframework.beans.factory.annotation.Autowired + +/** + * Proves the inbound half end to end, against a real process engine: a Slack message creates + * a case, and a reply in that case's thread continues it. + * + * Deliberately not transactional. The handler starts each message in its own + * `REQUIRES_NEW` transaction — the claim that makes two nodes safe has to be committed to + * mean anything — so a test-managed rollback would not undo the cases anyway, and holding a + * transaction open around the poll only hides the real behaviour. + */ +class SlackReceiveMessageIT : BaseIntegrationTest() { + @Autowired + lateinit var pluginService: PluginService + + @Autowired + lateinit var processDocumentService: ProcessDocumentService + + @Autowired + lateinit var repositoryService: RepositoryService + + @Autowired + lateinit var runtimeService: RuntimeService + + @Autowired + lateinit var pluginProcessLinkRepository: ValtimoPluginProcessLinkRepository + + @Autowired + lateinit var pollingService: SlackMessagePollingService + + private lateinit var server: MockWebServer + private lateinit var configuration: PluginConfiguration + private var historyBody: String = emptyHistory() + private var repliesBody: String = emptyHistory() + + @BeforeEach + fun setUp() { + startMockServer() + configuration = createSlackPluginConfiguration() + } + + /** + * Nothing here rolls back — the handler commits each message in its own transaction — so + * every test cleans up after itself. A process link left behind makes the next test fail + * to create its own, and a case left behind is counted by the next test's assertions. + */ + @AfterEach + fun tearDown() { + server.shutdown() + runWithoutAuthorization { + pluginProcessLinkRepository + .findByPluginActionDefinitionKey(SlackMessagePollingService.RECEIVE_MESSAGE_ACTION) + .forEach { pluginService.deleteProcessLink(it.id) } + pluginService.deletePluginConfiguration(configuration.id) + + listOf("SlackMessageStartProcess", "SlackReplyProcess") + .flatMap { instancesOf(it) } + .forEach { runtimeService.deleteProcessInstance(it, "test cleanup") } + } + } + + @Test + fun `should create a case for a message posted in the channel`() { + createProcessLink( + processDefinitionKey = "SlackMessageStartProcess", + activityId = "SlackMessageStart", + activityType = MESSAGE_START_EVENT_START, + actionProperties = """{"channel": "$CHANNEL"}""", + ) + historyBody = historyWith(ts = MESSAGE_TS, text = "Ik wil bezwaar maken") + + poll() + + val started = instancesOf("SlackMessageStartProcess") + assertThat(started).hasSize(1) + // The case has to know which conversation it came out of, or it can never answer it. + assertThat(variableOf(started.single(), SlackMessage.THREAD_TS_VARIABLE)).isEqualTo(MESSAGE_TS) + assertThat(variableOf(started.single(), SlackMessage.CHANNEL_VARIABLE)).isEqualTo(CHANNEL) + assertThat(variableOf(started.single(), "slackMessageText")).isEqualTo("Ik wil bezwaar maken") + } + + @Test + fun `should create only one case when the same message is read twice`() { + // Two nodes polling the same channel both fetch it, and a cursor re-read after a + // rollback offers it again. Neither may produce a second case. + createProcessLink( + processDefinitionKey = "SlackMessageStartProcess", + activityId = "SlackMessageStart", + activityType = MESSAGE_START_EVENT_START, + actionProperties = """{"channel": "$CHANNEL"}""", + ) + historyBody = historyWith(ts = MESSAGE_TS, text = "Ik wil bezwaar maken") + + poll() + poll() + + assertThat(instancesOf("SlackMessageStartProcess")).hasSize(1) + } + + @Test + fun `should not create a case for a message the plugin posted itself`() { + createProcessLink( + processDefinitionKey = "SlackMessageStartProcess", + activityId = "SlackMessageStart", + activityType = MESSAGE_START_EVENT_START, + actionProperties = """{"channel": "$CHANNEL"}""", + ) + historyBody = + historyWith( + ts = MESSAGE_TS, + text = "Uw bericht is ontvangen", + extraFields = """"subtype": "bot_message", "bot_id": "B012AB3CD",""", + ) + + poll() + + assertThat(instancesOf("SlackMessageStartProcess")).isEmpty() + } + + @Test + fun `should continue the case that is waiting for an answer in the thread`() { + val waiting = startReplyProcessAwaiting(threadTs = MESSAGE_TS) + val other = startReplyProcessAwaiting(threadTs = "1735680000.000000") + + createProcessLink( + processDefinitionKey = "SlackReplyProcess", + activityId = "AwaitSlackReply", + activityType = INTERMEDIATE_CATCH_EVENT_END, + actionProperties = """{"channel": "$CHANNEL"}""", + ) + repliesBody = historyWith(ts = REPLY_TS, text = "Ja, ga verder", threadTs = MESSAGE_TS) + + poll() + + // The reply is only for the case whose thread it names. The other one waits at the + // catch event, which is the whole point of correlating on the thread. + assertThat(isWaitingAt(waiting, "AwaitSlackReply")).isFalse() + assertThat(isWaitingAt(other, "AwaitSlackReply")).isTrue() + + // And it is asked about by thread id, rather than the whole channel being trawled. + val repliesRequest = findRequest("/api/conversations.replies") + assertThat(repliesRequest?.path).contains("ts=$MESSAGE_TS") + } + + @Test + fun `should not read a channel no process link names`() { + // The links are what put a channel in scope, so a configuration nobody links to must + // cost nothing at all. + poll() + + assertThat(findRequest("/api/conversations.history")).isNull() + } + + /** + * Runs one poll of the configuration under test. + * + * `pollConfiguration` rather than the scheduled `pollChannels`, because that one holds a + * ShedLock for a second afterwards and a test that polls twice in a row would have its + * second call skipped. + */ + private fun poll() { + val links = + pluginProcessLinkRepository + .findByPluginActionDefinitionKey(SlackMessagePollingService.RECEIVE_MESSAGE_ACTION) + .filter { it.pluginConfigurationId == configuration.id } + if (links.isEmpty()) return + runWithoutAuthorization { pollingService.pollConfiguration(configuration.id, links) } + } + + private fun startReplyProcessAwaiting(threadTs: String): String { + val request = + NewDocumentAndStartProcessRequest( + "SlackReplyProcess", + NewDocumentRequest( + DOCUMENT_DEFINITION_KEY, + DOCUMENT_DEFINITION_KEY, + "1.0.0", + MapperSingleton.get().readTree("""{"lastname": "Doe"}"""), + ), + ).withProcessVars( + mapOf( + SlackMessage.CHANNEL_VARIABLE to CHANNEL, + SlackMessage.THREAD_TS_VARIABLE to threadTs, + ), + ) + val result = runWithoutAuthorization { processDocumentService.newDocumentAndStartProcess(request) } + assertThat(result.errors()).isEmpty() + return result.resultingProcessInstanceId().orElseThrow().toString() + } + + private fun instancesOf(processDefinitionKey: String): List = + runtimeService + .createProcessInstanceQuery() + .processDefinitionKey(processDefinitionKey) + .list() + .map { it.processInstanceId } + + private fun isWaitingAt( + processInstanceId: String, + activityId: String, + ): Boolean = + runtimeService + .createExecutionQuery() + .processInstanceId(processInstanceId) + .activityId(activityId) + .list() + .isNotEmpty() + + private fun variableOf( + processInstanceId: String, + name: String, + ): Any? = + runtimeService + .createVariableInstanceQuery() + .processInstanceIdIn(processInstanceId) + .variableName(name) + .singleResult() + ?.value + + private fun createSlackPluginConfiguration(): PluginConfiguration { + val properties = """ + { + "url": "${server.url("/")}", + "token": "test-token", + "initialLookbackMinutes": 60 + }""" + + return runWithoutAuthorization { + pluginService.createPluginConfiguration( + "Slack receive configuration", + MapperSingleton.get().readTree(properties) as ObjectNode, + "slack", + ) + } + } + + private fun createProcessLink( + processDefinitionKey: String, + activityId: String, + activityType: com.ritense.processlink.domain.ActivityTypeWithEventName, + actionProperties: String, + ) { + val processDefinition = + repositoryService + .createProcessDefinitionQuery() + .processDefinitionKey(processDefinitionKey) + .latestVersion() + .singleResult() + + runWithoutAuthorization { + pluginService.createProcessLink( + PluginProcessLinkCreateDto( + processDefinition.id, + activityId, + configuration.id.id, + "receive-message", + MapperSingleton.get().readTree(actionProperties) as ObjectNode, + activityType, + ), + ) + } + } + + private fun startMockServer() { + val dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val body = + when (request.path?.substringBefore('?')) { + "/api/conversations.history" -> historyBody + "/api/conversations.replies" -> repliesBody + else -> return MockResponse().setResponseCode(404) + } + return MockResponse() + .addHeader("Content-Type", "application/json; charset=utf-8") + .setResponseCode(200) + .setBody(body) + } + } + server = MockWebServer() + server.dispatcher = dispatcher + server.start() + } + + private fun findRequest(path: String): RecordedRequest? { + val requests = mutableListOf() + while (true) { + val request = server.takeRequest(1, java.util.concurrent.TimeUnit.MILLISECONDS) ?: break + requests += request + } + return requests.firstOrNull { it.path?.substringBefore('?') == path } + } + + private fun historyWith( + ts: String, + text: String, + threadTs: String? = null, + extraFields: String = "", + ): String = + """ + { + "ok": true, + "messages": [ + { + "type": "message", + $extraFields + "user": "U012AB3CD", + "text": "$text", + "ts": "$ts" + ${threadTs?.let { ""","thread_ts": "$it"""" } ?: ""} + } + ], + "has_more": false, + "response_metadata": {"next_cursor": ""} + } + """.trimIndent() + + private fun emptyHistory(): String = + """{"ok": true, "messages": [], "has_more": false, "response_metadata": {"next_cursor": ""}}""" + + private companion object { + private const val CHANNEL = "C012AB3CD" + private const val MESSAGE_TS = "1735689600.000100" + private const val REPLY_TS = "1735690000.000600" + private const val DOCUMENT_DEFINITION_KEY = "profile" + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/client/SlackClientTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/client/SlackClientTest.kt new file mode 100644 index 0000000..52af43a --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/client/SlackClientTest.kt @@ -0,0 +1,302 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.client + +import com.ritense.valtimoplugins.slack.BaseTest +import com.ritense.valtimoplugins.slack.domain.SlackConnectionProperties +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.web.client.RestClient +import java.time.Duration + +class SlackClientTest : BaseTest() { + private lateinit var server: MockWebServer + private lateinit var client: SlackClient + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + client = SlackClient(RestClient.builder()) + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + @Test + fun `should read a channel oldest message first`() { + enqueueFile("/data/conversations-history-response.json") + + val result = client.conversationsHistory(connection(), CHANNEL, null) + + // Slack answers newest first; a cursor may only advance over a stretch that was + // handled in the order it was posted. + assertThat(result.messages.map { it.ts }) + .containsExactly("1735689600.000100", "1735689650.000200", "1735689900.000500") + assertThat(result.truncated).isFalse() + } + + @Test + fun `should drop the channel bookkeeping a real channel history is full of`() { + enqueueFile("/data/conversations-history-response.json") + + val result = client.conversationsHistory(connection(), CHANNEL, null) + + // channel_join carries no meaning for a process, and message_changed is an edit of an + // older message - resuming a case on one would let anybody re-trigger a process by + // editing a message from last year. + assertThat(result.messages.map { it.subtype }).doesNotContain("channel_join", "message_changed") + } + + @Test + fun `should carry over the fields a process needs`() { + enqueueFile("/data/conversations-history-response.json") + + val message = client.conversationsHistory(connection(), CHANNEL, null).messages.first() + + assertThat(message.channel).isEqualTo(CHANNEL) + assertThat(message.ts).isEqualTo("1735689600.000100") + assertThat(message.text).isEqualTo("Ik wil bezwaar maken") + assertThat(message.userId).isEqualTo("U012AB3CD") + assertThat(message.fileNames).containsExactly("bezwaarschrift.pdf") + assertThat(message.isFromBot).isFalse() + } + + @Test + fun `should recognise the plugin's own message as coming from an app`() { + enqueueFile("/data/conversations-history-response.json") + + val botMessage = + client + .conversationsHistory(connection(), CHANNEL, null) + .messages + .single { it.ts == "1735689650.000200" } + + assertThat(botMessage.isFromBot).isTrue() + assertThat(botMessage.botId).isEqualTo("B012AB3CD") + } + + @Test + fun `should ask Slack only for what it has not read yet`() { + enqueueFile("/data/conversations-history-response.json") + + client.conversationsHistory(connection(), CHANNEL, "1735689500.000000") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).startsWith("/api/conversations.history") + assertThat(request.path).contains("channel=$CHANNEL") + assertThat(request.path).contains("oldest=1735689500.000000") + // Exclusive, so the newest handled message is not handed back on every poll. + assertThat(request.path).contains("inclusive=false") + assertThat(request.getHeader("Authorization")).isEqualTo("Bearer $TOKEN") + } + + @Test + fun `should leave the thread parent to the channel history`() { + enqueueFile("/data/conversations-replies-response.json") + + val result = client.conversationsReplies(connection(), CHANNEL, "1735689600.000100", null) + + // The parent is part of Slack's answer, but it was already handled when it was + // posted; handling it again here would start a second case for it. + assertThat(result.messages.map { it.ts }).containsExactly("1735690000.000600") + assertThat(result.messages.single().isThreadReply).isTrue() + } + + @Test + fun `should hand back the replies of a thread in the order they were posted`() { + // conversations.replies answers oldest first, where conversations.history answers + // newest first. Reversing this one too would cost a case its answer: the poller + // signals the waiting execution per message, so the newest reply would move the case + // past its catch event and the earlier one - the answer it was waiting for - would + // then match no waiting execution and be dropped with its claim already written. + enqueueBody( + """ + { + "ok": true, + "messages": [ + {"type": "message", "user": "U1", "text": "vraag", "ts": "1735689600.000100", + "thread_ts": "1735689600.000100"}, + {"type": "message", "user": "U2", "text": "eerst", "ts": "1735690000.000600", + "thread_ts": "1735689600.000100"}, + {"type": "message", "user": "U2", "text": "daarna", "ts": "1735690100.000700", + "thread_ts": "1735689600.000100"} + ], + "has_more": false, + "response_metadata": {"next_cursor": ""} + } + """.trimIndent(), + ) + + val result = client.conversationsReplies(connection(), CHANNEL, "1735689600.000100", null) + + assertThat(result.messages.map { it.ts }) + .containsExactly("1735690000.000600", "1735690100.000700") + } + + @Test + fun `should upload a file whose name carries no extension`() { + // Indexing into a split on '.' threw here, and on "verslag.2026.pdf" it called the + // file a "2026". + enqueueBody("""{"ok": true}""") + + client.filesUpload(connection(), CHANNEL, "Zie bijlage", "LICENSE", "text".byteInputStream()) + + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("LICENSE") + assertThat(body).doesNotContain("filetype") + } + + @Test + fun `should read the file type from the last dot of the name`() { + enqueueBody("""{"ok": true}""") + + client.filesUpload(connection(), CHANNEL, null, "verslag.2026.pdf", "content".byteInputStream()) + + // Splitting on the first dot made this a file of type "2026" titled "verslag". + val body = server.takeRequest().body.readUtf8() + assertThat(partOf(body, "filetype")).isEqualTo("pdf") + assertThat(partOf(body, "title")).isEqualTo("verslag.2026") + } + + @Test + fun `should follow Slack's pagination and report when the page budget ran out`() { + enqueueBody(pageWith(ts = "1735689600.000100", nextCursor = "cursor-1")) + enqueueBody(pageWith(ts = "1735689700.000200", nextCursor = "cursor-2")) + + val result = client.conversationsHistory(connection(maxPagesPerPoll = 2), CHANNEL, null) + + assertThat(result.messages).hasSize(2) + // The caller has to know: moving a cursor past a truncated read skips messages that + // were never fetched, and a skipped message is a case that never starts. + assertThat(result.truncated).isTrue() + + server.takeRequest() + assertThat(server.takeRequest().path).contains("cursor=cursor-1") + } + + @Test + fun `should stop paging once Slack says there is no more`() { + enqueueBody(pageWith(ts = "1735689600.000100", nextCursor = null)) + + val result = client.conversationsHistory(connection(maxPagesPerPoll = 5), CHANNEL, null) + + assertThat(result.messages).hasSize(1) + assertThat(result.truncated).isFalse() + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `should post with the credentials it was handed`() { + // One client serves every configuration, so the workspace has to travel with the call. + // When it lived on the client instead, two process instances posting on two threads + // could overwrite each other's token between it being set and being used - sending one + // workspace's message to the other, signed with the other's credentials. + enqueueBody("""{"ok": true, "channel": "$CHANNEL", "ts": "1735689600.000100"}""") + enqueueBody("""{"ok": true, "channel": "$CHANNEL", "ts": "1735689600.000200"}""") + + client.chatPostMessage(connection(token = "token-workspace-a"), CHANNEL, "for A") + client.chatPostMessage(connection(token = "token-workspace-b"), CHANNEL, "for B") + + assertThat(server.takeRequest().getHeader("Authorization")).isEqualTo("Bearer token-workspace-a") + assertThat(server.takeRequest().getHeader("Authorization")).isEqualTo("Bearer token-workspace-b") + } + + @Test + fun `should return the timestamp of the message it posted`() { + // The case has to keep it: it is the thread the answers to this message will carry. + enqueueBody("""{"ok": true, "channel": "$CHANNEL", "ts": "1735689600.000100"}""") + + val response = client.chatPostMessage(connection(), CHANNEL, "Hello", threadTs = "1735689000.000001") + + assertThat(response.ts).isEqualTo("1735689600.000100") + assertThat(server.takeRequest().body.readUtf8()).contains("thread_ts", "1735689000.000001") + } + + @Test + fun `should fail on a Slack error even though Slack answers with 200`() { + server.enqueue( + MockResponse() + .addHeader("Content-Type", "application/json; charset=utf-8") + .setResponseCode(200) + .setBody("""{"ok": false, "error": "not_in_channel"}"""), + ) + + assertThatThrownBy { client.conversationsHistory(connection(), CHANNEL, null) } + .isInstanceOf(SlackException::class.java) + .hasMessage("not_in_channel") + } + + private fun connection( + maxPagesPerPoll: Int = 10, + token: String = TOKEN, + ) = SlackConnectionProperties( + baseUri = server.url("/").toUri(), + token = token, + messagesPerPage = 100, + maxPagesPerPoll = maxPagesPerPoll, + maxThreadsPerPoll = 50, + initialLookback = Duration.ofMinutes(15), + ) + + /** The value of one part of a multipart body, with the part's own headers stripped off. */ + private fun partOf( + body: String, + name: String, + ): String = + body + .substringAfter("name=\"$name\"") + .substringAfter("\r\n\r\n") + .substringBefore("\r\n--") + + private fun enqueueFile(fileName: String) = enqueueBody(readFileAsString(fileName)) + + private fun enqueueBody(body: String) { + server.enqueue( + MockResponse() + .addHeader("Content-Type", "application/json; charset=utf-8") + .setResponseCode(200) + .setBody(body), + ) + } + + private fun pageWith( + ts: String, + nextCursor: String?, + ): String = + """ + { + "ok": true, + "messages": [{"type": "message", "user": "U012AB3CD", "text": "Hallo", "ts": "$ts"}], + "has_more": ${nextCursor != null}, + "response_metadata": {"next_cursor": "${nextCursor ?: ""}"} + } + """.trimIndent() + + private companion object { + private const val CHANNEL = "C012AB3CD" + private const val TOKEN = "test-token" + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/IncomingSlackMessageHandlerTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/IncomingSlackMessageHandlerTest.kt new file mode 100644 index 0000000..13a1a63 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/IncomingSlackMessageHandlerTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginProcessLink +import com.ritense.valtimoplugins.slack.BaseTest +import com.ritense.valtimoplugins.slack.domain.ProcessedSlackMessage +import com.ritense.valtimoplugins.slack.domain.SlackMessage +import com.ritense.valtimoplugins.slack.repository.ProcessedSlackMessageRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class IncomingSlackMessageHandlerTest : BaseTest() { + private val objectMapper = ObjectMapper() + + private lateinit var processedSlackMessageRepository: ProcessedSlackMessageRepository + private lateinit var starter: SlackMessageProcessStarter + private lateinit var handler: IncomingSlackMessageHandler + + @BeforeEach + fun setUp() { + processedSlackMessageRepository = mock() + starter = mock() + + whenever(processedSlackMessageRepository.existsById(any())).thenReturn(false) + whenever(starter.start(any(), any())).thenReturn(true) + + handler = IncomingSlackMessageHandler(processedSlackMessageRepository, starter, objectMapper) + } + + @Test + fun `should claim and start for a new message`() { + val link = processLink("""{"channel": "$CHANNEL"}""") + + val handled = handler.handle(message(), CONFIGURATION_ID, listOf(link)) + + assertThat(handled).isTrue() + verify(starter).start(eq(link), any()) + + val claim = argumentCaptor() + verify(processedSlackMessageRepository).saveAndFlush(claim.capture()) + assertThat(claim.firstValue.messageIdentity).isEqualTo("$CONFIGURATION_ID|$CHANNEL/$TS") + assertThat(claim.firstValue.channel).isEqualTo(CHANNEL) + assertThat(claim.firstValue.messageTs).isEqualTo(TS) + } + + @Test + fun `should scope the claim to the plugin configuration`() { + // Two configurations may watch the same channel for different processes, so the claim + // key cannot be the message identity on its own. + handler.handle(message(), "configuration-a", listOf(processLink("""{"channel": "$CHANNEL"}"""))) + handler.handle(message(), "configuration-b", listOf(processLink("""{"channel": "$CHANNEL"}"""))) + + val claims = argumentCaptor() + verify(processedSlackMessageRepository, times(2)).saveAndFlush(claims.capture()) + assertThat(claims.allValues.map { it.messageIdentity }) + .containsExactly("configuration-a|$CHANNEL/$TS", "configuration-b|$CHANNEL/$TS") + } + + @Test + fun `should not start anything for a message it has already handled`() { + whenever(processedSlackMessageRepository.existsById(any())).thenReturn(true) + + val handled = handler.handle(message(), CONFIGURATION_ID, listOf(processLink("""{"channel": "$CHANNEL"}"""))) + + assertThat(handled).isFalse() + verify(processedSlackMessageRepository, never()).saveAndFlush(any()) + verify(starter, never()).start(any(), any()) + } + + @Test + fun `should ignore a message from another channel than the link names`() { + val link = processLink("""{"channel": "C999OTHER"}""") + + val handled = handler.handle(message(), CONFIGURATION_ID, listOf(link)) + + assertThat(handled).isFalse() + verify(starter, never()).start(any(), any()) + } + + @Test + fun `should ignore a link that names no channel`() { + // Nothing can be read for it, so a message reaching it came from a channel its author + // never named. + val handled = + handler.handle(message(), CONFIGURATION_ID, listOf(processLink("""{"messageContains": "hello"}"""))) + + assertThat(handled).isFalse() + verify(starter, never()).start(any(), any()) + } + + @Test + fun `should apply the text and user filters together`() { + val matching = + processLink("""{"channel": "$CHANNEL", "messageContains": "bezwaar", "userId": "U012AB3CD"}""") + val wrongUser = processLink("""{"channel": "$CHANNEL", "userId": "U999OTHER"}""") + val wrongText = processLink("""{"channel": "$CHANNEL", "messageContains": "vakantie"}""") + + handler.handle(message(), CONFIGURATION_ID, listOf(matching, wrongUser, wrongText)) + + verify(starter).start(eq(matching), any()) + verify(starter, never()).start(eq(wrongUser), any()) + verify(starter, never()).start(eq(wrongText), any()) + } + + @Test + fun `should ignore a message from an app unless the link asks for them`() { + // The plugin's own outgoing messages come back on the next poll. A link that accepts + // them and a process that answers them form a loop. + val ownMessage = message().copy(botId = "B012AB3CD", subtype = "bot_message") + + assertThat(handler.handle(ownMessage, CONFIGURATION_ID, listOf(processLink("""{"channel": "$CHANNEL"}""")))) + .isFalse() + + val opensUp = processLink("""{"channel": "$CHANNEL", "includeBotMessages": true}""") + assertThat(handler.handle(ownMessage, CONFIGURATION_ID, listOf(opensUp))).isTrue() + } + + @Test + fun `should separate the messages that open a conversation from the replies to them`() { + val startsOnly = processLink("""{"channel": "$CHANNEL", "threadScope": "THREAD_STARTS_ONLY"}""") + val repliesOnly = processLink("""{"channel": "$CHANNEL", "threadScope": "THREAD_REPLIES_ONLY"}""") + + handler.handle(message(), CONFIGURATION_ID, listOf(startsOnly, repliesOnly)) + verify(starter).start(eq(startsOnly), any()) + verify(starter, never()).start(eq(repliesOnly), any()) + + val reply = message().copy(ts = "1735689900.000500", threadTs = TS) + handler.handle(reply, CONFIGURATION_ID, listOf(startsOnly, repliesOnly)) + verify(starter).start(eq(repliesOnly), any()) + } + + @Test + fun `should report a message handled when any of the matching links started something`() { + val startEvent = processLink("""{"channel": "$CHANNEL"}""") + val catchEvent = processLink("""{"channel": "$CHANNEL"}""") + whenever(starter.start(eq(startEvent), any())).thenReturn(true) + whenever(starter.start(eq(catchEvent), any())).thenReturn(false) + + assertThat(handler.handle(message(), CONFIGURATION_ID, listOf(startEvent, catchEvent))).isTrue() + } + + @Test + fun `should report a message not handled when every matching link had nothing to start`() { + whenever(starter.start(any(), any())).thenReturn(false) + + assertThat(handler.handle(message(), CONFIGURATION_ID, listOf(processLink("""{"channel": "$CHANNEL"}""")))) + .isFalse() + } + + private fun message() = + SlackMessage( + channel = CHANNEL, + ts = TS, + text = "Ik wil bezwaar maken", + userId = "U012AB3CD", + userName = "jan", + ) + + private fun processLink(actionProperties: String): PluginProcessLink = + mock().also { + whenever(it.actionProperties).thenReturn(objectMapper.readTree(actionProperties) as ObjectNode) + whenever(it.activityId).thenReturn("start-event") + whenever(it.processDefinitionId).thenReturn("slack-intake-process:1:abc") + } + + private companion object { + private const val CHANNEL = "C012AB3CD" + private const val TS = "1735689600.000100" + private const val CONFIGURATION_ID = "3f6a0f4c-0b6a-4f8c-9b9b-1f2a3b4c5d6e" + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessagePollingServiceTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessagePollingServiceTest.kt new file mode 100644 index 0000000..a1a982c --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessagePollingServiceTest.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginConfigurationId +import com.ritense.plugin.domain.PluginProcessLink +import com.ritense.plugin.service.PluginService +import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository +import com.ritense.valtimoplugins.slack.BaseTest +import com.ritense.valtimoplugins.slack.client.SlackClient +import com.ritense.valtimoplugins.slack.domain.SlackChannelCursor +import com.ritense.valtimoplugins.slack.domain.SlackMessage +import com.ritense.valtimoplugins.slack.plugin.SlackPlugin +import com.ritense.valtimoplugins.slack.repository.ProcessedSlackMessageRepository +import com.ritense.valtimoplugins.slack.repository.SlackChannelCursorRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.math.BigDecimal +import java.net.URI +import java.time.Instant +import java.util.Optional +import java.util.UUID + +/** + * Covers the part of the poller that has no counterpart in the mail plugin: the channel + * cursor. A channel already contains everything ever said in it, so where a mailbox can be + * read from the start, a channel must not be. + */ +class SlackMessagePollingServiceTest : BaseTest() { + private val objectMapper = ObjectMapper() + private val configurationId = PluginConfigurationId.existingId(UUID.randomUUID()) + + private lateinit var pluginService: PluginService + private lateinit var slackClient: SlackClient + private lateinit var handler: IncomingSlackMessageHandler + private lateinit var starter: SlackMessageProcessStarter + private lateinit var cursorRepository: SlackChannelCursorRepository + private lateinit var pollingService: SlackMessagePollingService + + @BeforeEach + fun setUp() { + pluginService = mock() + slackClient = mock() + handler = mock() + starter = mock() + cursorRepository = mock() + + whenever(pluginService.createInstance(any())).doReturn(plugin()) + whenever(slackClient.conversationsHistory(any(), any(), any())).thenReturn(SlackClient.PagedMessages()) + whenever(cursorRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(starter.threadsAwaitingReply(any())).thenReturn(emptyMap()) + whenever(handler.handle(any(), any(), any())).thenReturn(true) + + pollingService = + SlackMessagePollingService( + mock(), + pluginService, + slackClient, + handler, + starter, + mock(), + cursorRepository, + objectMapper, + 90L, + ) + } + + @Test + fun `should start reading a new channel at the configured lookback, not at its beginning`() { + noCursorYet() + val before = Instant.now() + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + val created = argumentCaptor() + verify(cursorRepository).save(created.capture()) + assertThat(created.firstValue.channel).isEqualTo(CHANNEL) + + // 15 minutes is the default lookback; anything posted before it is never fetched, so + // adding an existing channel cannot turn years of chatter into cases. + val startedAt = BigDecimal(created.firstValue.lastMessageTs) + assertThat(startedAt).isLessThan(BigDecimal(SlackMessage.timestampOf(before.minusSeconds(14 * 60)))) + assertThat(startedAt).isGreaterThan(BigDecimal(SlackMessage.timestampOf(before.minusSeconds(16 * 60)))) + } + + @Test + fun `should ask Slack only for what the cursor has not covered`() { + existingCursor("1735689500.000000") + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + verify(slackClient).conversationsHistory(any(), eq(CHANNEL), eq("1735689500.000000")) + } + + @Test + fun `should move the cursor to the newest message it handled`() { + val cursor = existingCursor("1735689500.000000") + historyOf("1735689600.000100", "1735689700.000200") + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + assertThat(cursor.lastMessageTs).isEqualTo("1735689700.000200") + } + + @Test + fun `should hold the cursor at the last message before a failure`() { + // The failed message is offered again on the next poll. Moving past it would drop it + // silently, which for an incoming request means a case that never gets created. + val cursor = existingCursor("1735689500.000000") + historyOf("1735689600.000100", "1735689700.000200", "1735689800.000300") + whenever(handler.handle(argThatHasTs("1735689700.000200"), any(), any())) + .thenThrow(IllegalStateException("boom")) + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + assertThat(cursor.lastMessageTs).isEqualTo("1735689600.000100") + } + + @Test + fun `should keep handling the messages behind one that fails`() { + // The cursor stops, but the round does not: a message the engine chokes on must not + // hold up the ones behind it for as long as it keeps failing. They keep their claim, + // so being offered again next poll costs a lookup and nothing more. + val cursor = existingCursor("1735689500.000000") + historyOf("1735689600.000100", "1735689700.000200", "1735689800.000300") + whenever(handler.handle(argThatHasTs("1735689700.000200"), any(), any())) + .thenThrow(IllegalStateException("boom")) + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + verify(handler).handle(argThatHasTs("1735689800.000300"), any(), any()) + assertThat(cursor.lastMessageTs).isEqualTo("1735689600.000100") + } + + @Test + fun `should not let a thread reply carry the cursor past an unread channel message`() { + // The two reads are separate calls. A message posted to the channel between them can + // be older than a reply the second call returns, so advancing the cursor to the newest + // of both would put it past a message that was never read - and the next poll would + // not ask for it again. + val cursor = existingCursor("1735689500.000000") + historyOf("1735689600.000100") + whenever(starter.threadsAwaitingReply(any())).thenReturn(mapOf("1735689600.000100" to null)) + whenever(slackClient.conversationsReplies(any(), any(), any(), anyOrNull())) + .thenReturn(SlackClient.PagedMessages(listOf(message("1735699999.000900")))) + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + assertThat(cursor.lastMessageTs).isEqualTo("1735689600.000100") + } + + @Test + fun `should never move the cursor backwards`() { + // Thread replies and channel history are read separately, and an out-of-order value + // rewinding the channel would replay everything after it. + val cursor = existingCursor("1735689700.000200") + + assertThat(cursor.advanceTo("1735689600.000100")).isFalse() + assertThat(cursor.lastMessageTs).isEqualTo("1735689700.000200") + } + + @Test + fun `should read a thread from where the case waiting in it got to`() { + // Not from the channel's read position: a case that parks late - an asynchronous + // continuation is enough - would otherwise have its answer skipped, because the + // channel cursor has moved on past it in the meantime. + existingCursor("1735689500.000000") + whenever(starter.threadsAwaitingReply(any())).thenReturn(mapOf("1735689600.000100" to "1735689650.000150")) + whenever(slackClient.conversationsReplies(any(), any(), any(), anyOrNull())) + .thenReturn(SlackClient.PagedMessages(listOf(message("1735690000.000600")))) + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + verify(slackClient) + .conversationsReplies(any(), eq(CHANNEL), eq("1735689600.000100"), eq("1735689650.000150")) + verify(handler).handle(argThatHasTs("1735690000.000600"), any(), any()) + } + + @Test + fun `should read a thread from its start when the case has no recorded position`() { + existingCursor("1735689500.000000") + whenever(starter.threadsAwaitingReply(any())).thenReturn(mapOf("1735689600.000100" to null)) + whenever(slackClient.conversationsReplies(any(), any(), any(), anyOrNull())) + .thenReturn(SlackClient.PagedMessages()) + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + verify(slackClient).conversationsReplies(any(), eq(CHANNEL), eq("1735689600.000100"), eq(null)) + } + + @Test + fun `should read a thread once, from the position of whichever case is furthest behind`() { + // Two links, both waiting in the same thread, one of them further behind. Reading from + // the later position would cost the other one its answer. + existingCursor("1735689500.000000") + val linkA = processLink() + val linkB = processLink() + whenever(starter.threadsAwaitingReply(eq(linkA))) + .thenReturn(mapOf("1735689600.000100" to "1735689900.000500")) + whenever(starter.threadsAwaitingReply(eq(linkB))) + .thenReturn(mapOf("1735689600.000100" to "1735689650.000150")) + whenever(slackClient.conversationsReplies(any(), any(), any(), anyOrNull())) + .thenReturn(SlackClient.PagedMessages()) + + pollingService.pollConfiguration(configurationId, listOf(linkA, linkB)) + + verify(slackClient, times(1)) + .conversationsReplies(any(), eq(CHANNEL), eq("1735689600.000100"), eq("1735689650.000150")) + } + + @Test + fun `should serve the threads furthest behind first when there are more than the budget`() { + // Otherwise the same threads are dropped every poll, and the cases waiting in them + // never continue - quietly, and for good. + existingCursor("1735689500.000000") + whenever(pluginService.createInstance(any())) + .doReturn(plugin().also { it.maxThreadsPerPoll = 2 }) + whenever(starter.threadsAwaitingReply(any())).thenReturn( + mapOf( + "thread-newest" to "1735689900.000500", + "thread-oldest" to "1735689600.000100", + "thread-middle" to "1735689700.000200", + ), + ) + whenever(slackClient.conversationsReplies(any(), any(), any(), anyOrNull())) + .thenReturn(SlackClient.PagedMessages()) + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + verify(slackClient).conversationsReplies(any(), any(), eq("thread-oldest"), anyOrNull()) + verify(slackClient).conversationsReplies(any(), any(), eq("thread-middle"), anyOrNull()) + verify(slackClient, never()).conversationsReplies(any(), any(), eq("thread-newest"), anyOrNull()) + } + + @Test + fun `should keep reading the channel when one thread cannot be read`() { + // A thread whose parent was deleted answers with an error forever. + existingCursor("1735689500.000000") + whenever(starter.threadsAwaitingReply(any())).thenReturn(mapOf("1735689600.000100" to "1735689650.000150")) + whenever(slackClient.conversationsReplies(any(), any(), any(), anyOrNull())) + .thenThrow(IllegalStateException("thread_not_found")) + historyOf("1735689900.000500") + + pollingService.pollConfiguration(configurationId, listOf(processLink())) + + verify(handler).handle(argThatHasTs("1735689900.000500"), any(), any()) + } + + @Test + fun `should skip a link that names no channel`() { + noCursorYet() + + pollingService.pollConfiguration(configurationId, listOf(processLink(actionProperties = "{}"))) + + verify(slackClient, never()).conversationsHistory(any(), any(), any()) + } + + private fun noCursorYet() { + whenever(cursorRepository.findById(any())).thenReturn(Optional.empty()) + } + + private fun existingCursor(lastMessageTs: String): SlackChannelCursor { + val cursor = + SlackChannelCursor( + cursorId = SlackChannelCursor.idOf(configurationId.id.toString(), CHANNEL), + pluginConfigurationId = configurationId.id.toString(), + channel = CHANNEL, + lastMessageTs = lastMessageTs, + ) + whenever(cursorRepository.findById(any())).thenReturn(Optional.of(cursor)) + return cursor + } + + private fun historyOf(vararg timestamps: String) { + whenever(slackClient.conversationsHistory(any(), any(), any())) + .thenReturn(SlackClient.PagedMessages(timestamps.map { message(it) })) + } + + private fun message(ts: String) = SlackMessage(channel = CHANNEL, ts = ts, text = "Hallo", userId = "U012AB3CD") + + private fun argThatHasTs(ts: String): SlackMessage = argThat { this.ts == ts } + + private fun processLink(actionProperties: String = """{"channel": "$CHANNEL"}"""): PluginProcessLink = + mock().also { + whenever(it.actionProperties).thenReturn(objectMapper.readTree(actionProperties) as ObjectNode) + whenever(it.activityId).thenReturn("start-event") + whenever(it.processDefinitionId).thenReturn("slack-intake-process:1:abc") + } + + private fun plugin(): SlackPlugin = + SlackPlugin(slackClient, mock()).also { + it.url = URI.create("https://slack.example.com/") + it.token = "test-token" + } + + private companion object { + private const val CHANNEL = "C012AB3CD" + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarterTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarterTest.kt new file mode 100644 index 0000000..24243a9 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarterTest.kt @@ -0,0 +1,292 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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 com.ritense.valtimoplugins.slack.service + +import com.ritense.case.service.CaseDefinitionService +import com.ritense.plugin.domain.PluginProcessLink +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.service.ProcessPropertyService +import com.ritense.valtimoplugins.slack.BaseTest +import com.ritense.valtimoplugins.slack.domain.SlackMessage +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.entry +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.engine.RuntimeService +import org.operaton.bpm.engine.runtime.Execution +import org.operaton.bpm.engine.runtime.ExecutionQuery + +/** + * Covers the reply side of the starter: which of the instances parked at a receive task a + * given Slack message may resume. + * + * The broadcast this guards against is not a hypothetical. The execution query is scoped to + * a process definition and an activity, not to a case, so without correlation every case + * waiting for its own answer would be handed the first message that arrived for any of them. + */ +class SlackMessageProcessStarterTest : BaseTest() { + private lateinit var runtimeService: RuntimeService + private lateinit var executionQuery: ExecutionQuery + private lateinit var starter: SlackMessageProcessStarter + + @BeforeEach + fun setUp() { + runtimeService = mock() + executionQuery = mock() + + whenever(runtimeService.createExecutionQuery()).thenReturn(executionQuery) + whenever(executionQuery.processDefinitionId(any())).thenReturn(executionQuery) + whenever(executionQuery.activityId(any())).thenReturn(executionQuery) + + starter = + SlackMessageProcessStarter( + runtimeService, + mock(), + mock(), + mock(), + mock(), + mock(), + ) + } + + @Test + fun `should resume only the case the reply belongs to`() { + waiting("execution-a" to "instance-a", "execution-b" to "instance-b") + awaits("instance-a", "1735689600.000100") + awaits("instance-b", "1735689600.000200") + + val resumed = starter.start(receiveTask(), reply(threadTs = "1735689600.000200")) + + assertThat(resumed).isTrue() + verify(runtimeService).signal(eq("execution-b"), any>()) + verify(runtimeService, never()).signal(eq("execution-a"), any>()) + } + + @Test + fun `should resume nothing when the reply belongs to a thread nobody is waiting on`() { + waiting("execution-a" to "instance-a") + awaits("instance-a", "1735689600.000100") + + val resumed = starter.start(receiveTask(), reply(threadTs = "1735689600.000999")) + + assertThat(resumed).isFalse() + verify(runtimeService, never()).signal(any(), any>()) + } + + @Test + fun `should resume nothing for a message that is part of no thread at all`() { + // A fresh message in the channel is not an answer to anything. Signalling on it is + // exactly the broadcast this correlation exists to prevent. + waiting("execution-a" to "instance-a", "execution-b" to "instance-b") + awaits("instance-a", "1735689600.000100") + awaits("instance-b", "1735689600.000200") + + val resumed = starter.start(receiveTask(), reply(threadTs = null)) + + assertThat(resumed).isFalse() + verify(runtimeService, never()).signal(any(), any>()) + } + + @Test + fun `should treat the thread parent itself as belonging to its own thread`() { + // Slack reports the parent of a thread with thread_ts equal to its own ts, and a case + // started by that message remembers exactly that value. + waiting("execution-a" to "instance-a") + awaits("instance-a", "1735689600.000100") + + val parent = + SlackMessage( + channel = CHANNEL, + ts = "1735689600.000100", + threadTs = "1735689600.000100", + text = "Original question", + userId = "U012AB3CD", + ) + + assertThat(starter.start(receiveTask(), parent)).isTrue() + verify(runtimeService).signal(eq("execution-a"), any>()) + } + + @Test + fun `should match timestamps that differ only in trailing precision`() { + // Slack trims trailing zeroes on some payloads, so the value a case stored and the + // value a later message reports can be the same instant spelled two ways. + waiting("execution-a" to "instance-a") + awaits("instance-a", "1735689600.0001") + + val resumed = starter.start(receiveTask(), reply(threadTs = "1735689600.000100")) + + assertThat(resumed).isTrue() + verify(runtimeService).signal(eq("execution-a"), any>()) + } + + @Test + fun `should resume every case in the thread when one instance waits at the task twice`() { + waiting("execution-a" to "instance-a", "execution-b" to "instance-a") + awaits("instance-a", "1735689600.000100") + + val resumed = starter.start(receiveTask(), reply(threadTs = "1735689600.000100")) + + assertThat(resumed).isTrue() + verify(runtimeService).signal(eq("execution-a"), any>()) + verify(runtimeService).signal(eq("execution-b"), any>()) + } + + @Test + fun `should hand the message on as process variables when it does resume`() { + waiting("execution-a" to "instance-a") + awaits("instance-a", "1735689600.000100") + + val message = reply(threadTs = "1735689600.000100") + starter.start(receiveTask(), message) + + verify(runtimeService).signal(eq("execution-a"), eq(message.toProcessVariables())) + } + + @Test + fun `should report nothing resumed when no execution is waiting`() { + waiting() + + val resumed = starter.start(receiveTask(), reply(threadTs = "1735689600.000100")) + + assertThat(resumed).isFalse() + verify(runtimeService, never()).signal(any(), any>()) + } + + @Test + fun `should report the threads waiting cases expect an answer in`() { + // This is what keeps the poller from having to track every thread in the workspace: + // it asks Slack about the open conversations only. + waiting("execution-a" to "instance-a", "execution-b" to "instance-b") + awaits("instance-a", "1735689600.000100") + awaits("instance-b", "1735689600.000200") + + assertThat(starter.threadsAwaitingReply(receiveTask())) + .containsOnlyKeys("1735689600.000100", "1735689600.000200") + } + + @Test + fun `should report each thread with the position of the case waiting in it`() { + // The position comes from the case, not from how far the channel has been read: a + // case that parks late would otherwise have its answer skipped, because the channel + // has moved on past it in the meantime. + waiting("execution-a" to "instance-a") + awaits("instance-a", "1735689600.000100") + hasSeen("instance-a", "1735689650.000150") + + assertThat(starter.threadsAwaitingReply(receiveTask())) + .containsExactly(entry("1735689600.000100", "1735689650.000150")) + } + + @Test + fun `should report no position for a case that has not recorded one`() { + // Read the thread from its start then, and let the claim table drop what was handled. + waiting("execution-a" to "instance-a") + awaits("instance-a", "1735689600.000100") + + assertThat(starter.threadsAwaitingReply(receiveTask())) + .containsExactly(entry("1735689600.000100", null)) + } + + @Test + fun `should report the earliest position when two cases wait in one thread`() { + // The thread is read once. Reading it from the later position would cost the case + // that is further behind its answer. + waiting("execution-a" to "instance-a", "execution-b" to "instance-b") + awaits("instance-a", "1735689600.000100") + awaits("instance-b", "1735689600.000100") + hasSeen("instance-a", "1735689900.000500") + hasSeen("instance-b", "1735689650.000150") + + assertThat(starter.threadsAwaitingReply(receiveTask())) + .containsExactly(entry("1735689600.000100", "1735689650.000150")) + } + + @Test + fun `should report no threads for a message start event`() { + // A start event has nothing waiting at it by definition, and querying executions for + // one would cost a call per poll to learn that. + val startEvent = + mock().also { + whenever(it.activityType).thenReturn(ActivityTypeWithEventName.MESSAGE_START_EVENT_START) + } + + assertThat(starter.threadsAwaitingReply(startEvent)).isEmpty() + verify(runtimeService, never()).createExecutionQuery() + } + + private fun waiting(vararg executions: Pair) { + // Built before the stubbing rather than inside it: stubbing one mock while another + // stubbing call is still open is what Mockito calls unfinished stubbing. + val parked = + executions.map { (executionId, processInstanceId) -> + mock().also { + whenever(it.id).thenReturn(executionId) + whenever(it.processInstanceId).thenReturn(processInstanceId) + } + } + whenever(executionQuery.list()).thenReturn(parked) + } + + /** Records which thread the case behind [processInstanceId] expects an answer in. */ + private fun awaits( + processInstanceId: String, + threadTs: String, + ) { + whenever(runtimeService.getVariable(processInstanceId, SlackMessage.THREAD_TS_VARIABLE)) + .thenReturn(threadTs) + } + + /** Records the last message the case behind [processInstanceId] has already seen. */ + private fun hasSeen( + processInstanceId: String, + messageTs: String, + ) { + whenever(runtimeService.getVariable(processInstanceId, SlackMessage.MESSAGE_TS_VARIABLE)) + .thenReturn(messageTs) + } + + private fun receiveTask(): PluginProcessLink = + mock().also { + whenever(it.activityType).thenReturn(ActivityTypeWithEventName.RECEIVE_TASK_END) + whenever(it.activityId).thenReturn("await-reply") + whenever(it.processDefinitionId).thenReturn("slack-intake-process:1:abc") + } + + private fun reply(threadTs: String?) = + SlackMessage( + channel = CHANNEL, + ts = "1735689900.000500", + threadTs = threadTs, + text = "Yes, go ahead", + userId = "U012AB3CD", + userName = "jan", + ) + + private companion object { + private const val CHANNEL = "C012AB3CD" + } +} diff --git a/backend/plugin/src/test/resources/config/application.yml b/backend/plugin/src/test/resources/config/application.yml index 8503dda..776f2ab 100644 --- a/backend/plugin/src/test/resources/config/application.yml +++ b/backend/plugin/src/test/resources/config/application.yml @@ -45,8 +45,15 @@ valtimo: plugin: encryption-secret: "abcdefghijklmnop" database: postgres + slack: + # The poller bean is wired up so that the context is still verified, but its cron is + # pushed out of reach: a scheduled run firing mid-test would read a channel the test + # under way did not ask about. Tests that exercise the poller call it directly. + poll-cron: "0 0 4 1 1 *" + retention-cron: "0 30 4 1 1 *" logging: level: org.springframework.beans: DEBUG + com.ritense.valtimoplugins.slack: DEBUG reactor.netty.http.client.HttpClient: DEBUG diff --git a/backend/plugin/src/test/resources/config/case/profile/1-0-0/bpmn/slack-message-process.bpmn b/backend/plugin/src/test/resources/config/case/profile/1-0-0/bpmn/slack-message-process.bpmn new file mode 100644 index 0000000..b1a3015 --- /dev/null +++ b/backend/plugin/src/test/resources/config/case/profile/1-0-0/bpmn/slack-message-process.bpmn @@ -0,0 +1,95 @@ + + + + + + + + + + Flow_start_to_task + + + + + + Flow_start_to_task + Flow_task_to_end + + + + Flow_task_to_end + + + + + + Flow_to_await + + + + Flow_to_await + Flow_to_reply_end + + + + + Flow_to_reply_end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/plugin/src/test/resources/config/case/profile/1-0-0/process-document-link/profile.process-document-link.json b/backend/plugin/src/test/resources/config/case/profile/1-0-0/process-document-link/profile.process-document-link.json index 78ff6cb..a104ebf 100644 --- a/backend/plugin/src/test/resources/config/case/profile/1-0-0/process-document-link/profile.process-document-link.json +++ b/backend/plugin/src/test/resources/config/case/profile/1-0-0/process-document-link/profile.process-document-link.json @@ -4,5 +4,17 @@ "canInitializeDocument": true, "startableByUser": true, "processIsVisibleInMenu": true + }, + { + "processDefinitionKey": "SlackMessageStartProcess", + "canInitializeDocument": true, + "startableByUser": false, + "processIsVisibleInMenu": false + }, + { + "processDefinitionKey": "SlackReplyProcess", + "canInitializeDocument": true, + "startableByUser": true, + "processIsVisibleInMenu": false } ] diff --git a/backend/plugin/src/test/resources/data/conversations-history-response.json b/backend/plugin/src/test/resources/data/conversations-history-response.json new file mode 100644 index 0000000..76fea4e --- /dev/null +++ b/backend/plugin/src/test/resources/data/conversations-history-response.json @@ -0,0 +1,60 @@ +{ + "ok": true, + "messages": [ + { + "type": "message", + "user": "U012AB3CD", + "text": "En nog een vraag", + "ts": "1735689900.000500", + "thread_ts": "1735689600.000100", + "team": "T012AB3CD" + }, + { + "type": "message", + "subtype": "channel_join", + "user": "U099ZZ9ZZ", + "text": "<@U099ZZ9ZZ> has joined the channel", + "ts": "1735689800.000400" + }, + { + "type": "message", + "subtype": "message_changed", + "hidden": true, + "ts": "1735689700.000300", + "message": { + "type": "message", + "user": "U012AB3CD", + "text": "Ik wil bezwaar maken (aangepast)", + "ts": "1735689600.000100" + } + }, + { + "type": "message", + "subtype": "bot_message", + "bot_id": "B012AB3CD", + "username": "Valtimo messenger", + "text": "Uw bericht is ontvangen", + "ts": "1735689650.000200", + "app_id": "A012AB3CD" + }, + { + "type": "message", + "user": "U012AB3CD", + "text": "Ik wil bezwaar maken", + "ts": "1735689600.000100", + "team": "T012AB3CD", + "files": [ + { + "id": "F012AB3CD", + "name": "bezwaarschrift.pdf", + "mimetype": "application/pdf" + } + ] + } + ], + "has_more": false, + "pin_count": 0, + "response_metadata": { + "next_cursor": "" + } +} diff --git a/backend/plugin/src/test/resources/data/conversations-replies-response.json b/backend/plugin/src/test/resources/data/conversations-replies-response.json new file mode 100644 index 0000000..a9cb8b6 --- /dev/null +++ b/backend/plugin/src/test/resources/data/conversations-replies-response.json @@ -0,0 +1,25 @@ +{ + "ok": true, + "messages": [ + { + "type": "message", + "user": "U012AB3CD", + "text": "Ik wil bezwaar maken", + "thread_ts": "1735689600.000100", + "reply_count": 1, + "ts": "1735689600.000100" + }, + { + "type": "message", + "user": "U045CD6EF", + "text": "Bedankt, wij pakken het op", + "thread_ts": "1735689600.000100", + "parent_user_id": "U012AB3CD", + "ts": "1735690000.000600" + } + ], + "has_more": false, + "response_metadata": { + "next_cursor": "" + } +} diff --git a/documentation/release-notes.md b/documentation/release-notes.md index 0c7ba24..ba32139 100644 --- a/documentation/release-notes.md +++ b/documentation/release-notes.md @@ -2,6 +2,15 @@ Overzicht van wijzigingen per versie van de Slack-plugin. +## 6.1.0 + +Nieuwe actie `receive-message`: een Slack-kanaal uitlezen en per bericht een dossier starten, +of een dossier vervolgen met het antwoord in de thread. Een planner leest alleen de kanalen +waar een proceskoppeling naar verwijst, slaat de geschiedenis van een kanaal over en houdt +per kanaal bij hoe ver het gelezen is. `post-message` legt daarnaast vast in welke thread het +bericht is geplaatst, zodat een dossier op de antwoorden kan wachten, en kan met de nieuwe +eigenschap *Thread* zelf in een bestaande thread antwoorden. + ## 6.0.1 Valtimo bijgewerkt naar versie 13.41.0. diff --git a/frontend/projects/plugin/package.json b/frontend/projects/plugin/package.json index 5d252c4..92a5dba 100644 --- a/frontend/projects/plugin/package.json +++ b/frontend/projects/plugin/package.json @@ -1,7 +1,7 @@ { "name": "@valtimo-plugins/slack", "license": "EUPL-1.2", - "version": "6.0.1", + "version": "6.1.0", "peerDependencies": { "@angular/common": "19.2.20", "@angular/core": "19.2.20" diff --git a/frontend/projects/plugin/src/lib/components/post-message/post-message-configuration.component.html b/frontend/projects/plugin/src/lib/components/post-message/post-message-configuration.component.html index 530005a..e3ce55c 100644 --- a/frontend/projects/plugin/src/lib/components/post-message/post-message-configuration.component.html +++ b/frontend/projects/plugin/src/lib/components/post-message/post-message-configuration.component.html @@ -39,4 +39,14 @@ [required]="true" [tooltip]="'messageTooltip' | pluginTranslate: pluginId | async" > + diff --git a/frontend/projects/plugin/src/lib/components/receive-message/receive-message-configuration.component.html b/frontend/projects/plugin/src/lib/components/receive-message/receive-message-configuration.component.html new file mode 100644 index 0000000..36eb830 --- /dev/null +++ b/frontend/projects/plugin/src/lib/components/receive-message/receive-message-configuration.component.html @@ -0,0 +1,80 @@ + + +

    {{ 'receiveMessageDescription' | pluginTranslate: pluginId | async }}

    + + + + + + + + + + + + diff --git a/frontend/projects/plugin/src/lib/components/receive-message/receive-message-configuration.component.ts b/frontend/projects/plugin/src/lib/components/receive-message/receive-message-configuration.component.ts new file mode 100644 index 0000000..e4e916b --- /dev/null +++ b/frontend/projects/plugin/src/lib/components/receive-message/receive-message-configuration.component.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * 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. + */ + +import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core'; +import { + FunctionConfigurationComponent, + FunctionConfigurationData, + PluginTranslationService, +} from '@valtimo/plugin'; +import {SelectItem} from '@valtimo/components'; +import {TranslateService} from '@ngx-translate/core'; +import {BehaviorSubject, combineLatest, map, Observable, Subscription, take} from 'rxjs'; +import {ReceiveMessageConfig, THREAD_SCOPES} from '../../models'; + +@Component({ + standalone: false, + selector: 'valtimo-receive-message-configuration', + templateUrl: './receive-message-configuration.component.html', +}) +export class ReceiveMessageConfigurationComponent + implements FunctionConfigurationComponent, OnInit, OnDestroy +{ + @Input() save$!: Observable; + @Input() disabled$!: Observable; + @Input() pluginId!: string; + @Input() prefillConfiguration$!: Observable; + @Output() valid: EventEmitter = new EventEmitter(); + @Output() configuration: EventEmitter = + new EventEmitter(); + + constructor( + private readonly translateService: TranslateService, + private readonly pluginTranslationService: PluginTranslationService + ) {} + + /** + * Rebuilt on every language change: the labels come from the plugin's own translations, + * and `instant` reads whatever language is active at the moment it is called. + */ + readonly threadScopeItems$: Observable = this.translateService + .stream('key') + .pipe( + map(() => + [THREAD_SCOPES.ANY, THREAD_SCOPES.THREAD_STARTS_ONLY, THREAD_SCOPES.THREAD_REPLIES_ONLY].map( + scope => ({ + id: scope, + text: this.pluginTranslationService.instant(`threadScope.${scope}`, this.pluginId), + }) + ) + ) + ); + + private saveSubscription!: Subscription; + private readonly formValue$ = new BehaviorSubject(null); + private readonly valid$ = new BehaviorSubject(false); + + ngOnInit(): void { + this.openSaveSubscription(); + } + + ngOnDestroy(): void { + this.saveSubscription?.unsubscribe(); + } + + formValueChange(formValue: ReceiveMessageConfig): void { + this.formValue$.next(formValue); + this.handleValid(formValue); + } + + private handleValid(formValue: ReceiveMessageConfig): void { + // The channel is the only field that is not a filter: without it there is nothing for + // the poller to read, so an empty form is not a valid "receive everything". + const valid = !!formValue.channel; + + this.valid$.next(valid); + this.valid.emit(valid); + } + + private openSaveSubscription(): void { + this.saveSubscription = this.save$?.subscribe(() => { + combineLatest([this.formValue$, this.valid$]) + .pipe(take(1)) + .subscribe(([formValue, valid]) => { + if (valid) { + this.configuration.emit(formValue!); + } + }); + }); + } +} diff --git a/frontend/projects/plugin/src/lib/components/slack-configuration/slack-configuration.component.html b/frontend/projects/plugin/src/lib/components/slack-configuration/slack-configuration.component.html index 5df2ef8..931cad5 100644 --- a/frontend/projects/plugin/src/lib/components/slack-configuration/slack-configuration.component.html +++ b/frontend/projects/plugin/src/lib/components/slack-configuration/slack-configuration.component.html @@ -51,10 +51,58 @@ [title]="'token' | pluginTranslate: pluginId | async" [margin]="true" [disabled]="obs.disabled" - [defaultValue]="obs.prefill?.clientSecret" + [defaultValue]="obs.prefill?.token" [required]="true" [fullWidth]="true" [tooltip]="'tokenTooltip' | pluginTranslate: pluginId | async" > + + + + + + + + + + + + diff --git a/frontend/projects/plugin/src/lib/models/config.ts b/frontend/projects/plugin/src/lib/models/config.ts index 1ab11a0..4161392 100644 --- a/frontend/projects/plugin/src/lib/models/config.ts +++ b/frontend/projects/plugin/src/lib/models/config.ts @@ -16,14 +16,32 @@ import {PluginConfigurationData} from '@valtimo/plugin'; +/** + * Keep these in lockstep with `ReceiveMessageProperties` in Kotlin. The backend treats a + * value it does not recognise as "no filter", so a value that drifts here surfaces as a + * process that fires too often rather than as an error on save. + */ +const THREAD_SCOPES = { + ANY: 'ANY', + THREAD_STARTS_ONLY: 'THREAD_STARTS_ONLY', + THREAD_REPLIES_ONLY: 'THREAD_REPLIES_ONLY', +} as const; + +type ThreadScope = (typeof THREAD_SCOPES)[keyof typeof THREAD_SCOPES]; + interface SlackConfig extends PluginConfigurationData { url: string; token: string; + messagesPerPage?: number; + maxPagesPerPoll?: number; + maxThreadsPerPoll?: number; + initialLookbackMinutes?: number; } interface PostMessageConfig { channel: string; message: string; + threadTs?: string; } interface PostMessageWithFileConfig { @@ -32,4 +50,23 @@ interface PostMessageWithFileConfig { fileName?: string; } -export {SlackConfig, PostMessageConfig, PostMessageWithFileConfig}; +/** + * Action properties of the `receive-message` process link. Only the channel is required — + * the rest are optional filters, and all of them are AND-ed. + */ +interface ReceiveMessageConfig { + channel: string; + messageContains?: string; + userId?: string; + includeBotMessages?: boolean; + threadScope?: ThreadScope; +} + +export { + PostMessageConfig, + PostMessageWithFileConfig, + ReceiveMessageConfig, + SlackConfig, + THREAD_SCOPES, + ThreadScope, +}; diff --git a/frontend/projects/plugin/src/lib/slack-plugin-module.ts b/frontend/projects/plugin/src/lib/slack-plugin-module.ts index 972e6d0..33b0f76 100644 --- a/frontend/projects/plugin/src/lib/slack-plugin-module.ts +++ b/frontend/projects/plugin/src/lib/slack-plugin-module.ts @@ -18,21 +18,31 @@ import {NgModule} from '@angular/core'; import {SlackConfigurationComponent} from './components/slack-configuration/slack-configuration.component'; import {CommonModule} from '@angular/common'; import {PluginTranslatePipeModule} from '@valtimo/plugin'; -import {FormModule, InputModule, ParagraphModule} from '@valtimo/components'; +import {FormModule, InputModule, ParagraphModule, SelectModule} from '@valtimo/components'; import {PostMessageWithFileConfigurationComponent} from './components/post-message-with-file/post-message-with-file-configuration.component'; import {PostMessageConfigurationComponent} from './components/post-message/post-message-configuration.component'; +import {ReceiveMessageConfigurationComponent} from './components/receive-message/receive-message-configuration.component'; @NgModule({ declarations: [ SlackConfigurationComponent, PostMessageConfigurationComponent, PostMessageWithFileConfigurationComponent, + ReceiveMessageConfigurationComponent, + ], + imports: [ + CommonModule, + PluginTranslatePipeModule, + FormModule, + InputModule, + ParagraphModule, + SelectModule, ], - imports: [CommonModule, PluginTranslatePipeModule, FormModule, InputModule, ParagraphModule], exports: [ SlackConfigurationComponent, PostMessageConfigurationComponent, PostMessageWithFileConfigurationComponent, + ReceiveMessageConfigurationComponent, ], }) export class SlackPluginModule {} diff --git a/frontend/projects/plugin/src/lib/slack-plugin.specification.ts b/frontend/projects/plugin/src/lib/slack-plugin.specification.ts index 76a3943..ad82238 100644 --- a/frontend/projects/plugin/src/lib/slack-plugin.specification.ts +++ b/frontend/projects/plugin/src/lib/slack-plugin.specification.ts @@ -19,6 +19,7 @@ import {SlackConfigurationComponent} from './components/slack-configuration/slac import {SLACK_PLUGIN_LOGO_BASE64} from './assets'; import {PostMessageWithFileConfigurationComponent} from './components/post-message-with-file/post-message-with-file-configuration.component'; import {PostMessageConfigurationComponent} from './components/post-message/post-message-configuration.component'; +import {ReceiveMessageConfigurationComponent} from './components/receive-message/receive-message-configuration.component'; const slackPluginSpecification: PluginSpecification = { pluginId: 'slack', @@ -27,20 +28,23 @@ const slackPluginSpecification: PluginSpecification = { functionConfigurationComponents: { 'post-message': PostMessageConfigurationComponent, 'post-message-with-file': PostMessageWithFileConfigurationComponent, + 'receive-message': ReceiveMessageConfigurationComponent, }, pluginTranslations: { nl: { title: 'Slack', 'post-message': 'Bericht plaatsen', 'post-message-with-file': 'Bericht plaatsen met bestand', + 'receive-message': 'Bericht ontvangen', url: 'Slack URL', urlTooltip: 'Een URL naar de REST API van Slack.', - description: 'Publiceer berichten met de Slack plugin.', + description: 'Publiceer berichten met de Slack plugin, en start of vervolg een proces op een bericht uit Slack.', configurationTitle: 'Configuratienaam', configurationTitleTooltip: 'De naam van de huidige plugin-configuratie. Onder deze naam kan de configuratie in de rest van de applicatie teruggevonden worden.', token: 'Token', - tokenTooltip: 'Authenticatie token met vereiste scopes.', + tokenTooltip: + 'Authenticatie token met vereiste scopes. Om berichten te ontvangen is channels:history nodig, en moet de app lid zijn van het kanaal.', channel: 'Kanaal', channelTooltip: 'Kanaal, privégroep of chatkanaal om een bericht naar te verzenden. Dit kan een gecodeerde ID of een naam zijn. Zie hieronder voor meer details.', @@ -51,19 +55,55 @@ const slackPluginSpecification: PluginSpecification = { messageTooltip: 'De berichttekst.', filename: 'Bestandsnaam', filenameTooltip: 'De bestandsnaam van het bestand.', + threadTs: 'Thread', + threadTsTooltip: + 'Laat leeg om een nieuw bericht in het kanaal te plaatsen. Vul de tijdstempel van een eerder bericht in om als antwoord in die thread te reageren, bijvoorbeeld pv:slackThreadTs.', + + initialLookbackMinutes: 'Terugkijken bij eerste ronde (minuten)', + initialLookbackMinutesTooltip: + 'Hoeveel minuten geschiedenis de eerste ronde van een kanaal meeneemt. Alles wat ouder is wordt nooit opgehaald, zodat een bestaand kanaal niet met terugwerkende kracht dossiers oplevert.', + messagesPerPage: 'Berichten per pagina', + messagesPerPageTooltip: 'Hoeveel berichten per aanvraag bij Slack worden opgehaald.', + maxPagesPerPoll: "Maximum aantal pagina's per ronde", + maxPagesPerPollTooltip: + "Begrenst hoeveel pagina's per kanaal per ronde worden gelezen. Wordt deze grens geraakt, dan staat dat als waarschuwing in het logboek.", + maxThreadsPerPoll: 'Maximum aantal threads per ronde', + maxThreadsPerPollTooltip: + 'Slack kost één aanvraag per thread waarop een dossier wacht. Deze grens voorkomt dat veel wachtende dossiers het lezen van het kanaal zelf blokkeren.', + + receiveMessageDescription: + 'Start of vervolgt dit proces voor elk bericht in het opgegeven kanaal. Vul alleen een kanaal in om alle berichten te verwerken, of voeg een filter toe om alleen bepaalde berichten door dit proces te laten oppakken.', + receiveChannel: 'Kanaal-ID', + receiveChannelTooltip: + 'De ID van het kanaal dat gelezen wordt, bijvoorbeeld C012AB3CD. Een naam met # werkt hier niet: Slack leest geschiedenis alleen op ID.', + messageContains: 'Bericht bevat', + messageContainsTooltip: 'Filtert op een deel van de berichttekst.', + userId: 'Gebruiker-ID', + userIdTooltip: 'Filtert op de Slack-gebruiker die het bericht plaatste, bijvoorbeeld U012AB3CD.', + threadScope: 'Berichten in threads', + threadScopeTooltip: + 'Handig als één kanaal twee processtappen voedt: een startgebeurtenis die alleen op nieuwe gesprekken reageert, en een tussentijdse gebeurtenis die alleen de antwoorden daarop ziet.', + 'threadScope.ANY': 'Alle berichten', + 'threadScope.THREAD_STARTS_ONLY': 'Alleen berichten die een gesprek beginnen', + 'threadScope.THREAD_REPLIES_ONLY': 'Alleen antwoorden in een thread', + includeBotMessages: 'Ook berichten van apps', + includeBotMessagesTooltip: + 'Uit laten staan tenzij nodig. De berichten die deze plugin zelf plaatst komen ook langs, en een proces dat daarop antwoordt blijft anders zichzelf aan de praat houden.', }, en: { title: 'Slack', 'post-message': 'Post message', 'post-message-with-file': 'Post message with file', + 'receive-message': 'Receive message', url: 'Slack URL', urlTooltip: 'A URL to the REST API of Slack', - description: 'Post messages with the Slack plugin.', + description: 'Post messages with the Slack plugin, and start or continue a process on a message from Slack.', configurationTitle: 'Configuration name', configurationTitleTooltip: 'The name of the current plugin configuration. Under this name, the configuration can be found in the rest of the application.', token: 'Token', - tokenTooltip: 'Authentication token bearing required scopes.', + tokenTooltip: + 'Authentication token bearing required scopes. Receiving messages needs channels:history, and the app has to be a member of the channel.', channel: 'Channel', channelTooltip: 'Channel, private group, or IM channel to send message to. Can be an encoded ID, or a name. See below for more details.', @@ -74,19 +114,56 @@ const slackPluginSpecification: PluginSpecification = { messageTooltip: 'The message text.', filename: 'Filename', filenameTooltip: 'The filename of the file.', + threadTs: 'Thread', + threadTsTooltip: + 'Leave empty to post a new message in the channel. Fill in the timestamp of an earlier message to answer inside that thread, for example pv:slackThreadTs.', + + initialLookbackMinutes: 'First poll looks back (minutes)', + initialLookbackMinutesTooltip: + 'How many minutes of history the first poll of a channel considers. Anything older is never read, so adding an existing channel does not turn its past into cases.', + messagesPerPage: 'Messages per page', + messagesPerPageTooltip: 'How many messages are read per request to Slack.', + maxPagesPerPoll: 'Maximum pages per poll', + maxPagesPerPollTooltip: + 'Caps how many pages of one channel are read per poll. Reaching the cap is reported as a warning in the log.', + maxThreadsPerPoll: 'Maximum threads per poll', + maxThreadsPerPollTooltip: + 'Slack costs one request per thread a case is waiting in. This cap keeps a large number of waiting cases from crowding out the channel itself.', + + receiveMessageDescription: + 'Starts or continues this process for each message posted in the given channel. Fill in only a channel to handle every message, or add a filter to let this process pick up part of them.', + receiveChannel: 'Channel id', + receiveChannelTooltip: + 'The id of the channel to read, for example C012AB3CD. A #name does not work here: Slack only serves history by id.', + messageContains: 'Message contains', + messageContainsTooltip: 'Filters on part of the message text.', + userId: 'User id', + userIdTooltip: 'Filters on the Slack user who posted the message, for example U012AB3CD.', + threadScope: 'Messages in threads', + threadScopeTooltip: + 'Useful when one channel feeds two steps: a start event that should only fire on new conversations, and a catch event that should only see the answers to them.', + 'threadScope.ANY': 'All messages', + 'threadScope.THREAD_STARTS_ONLY': 'Only messages that start a conversation', + 'threadScope.THREAD_REPLIES_ONLY': 'Only replies inside a thread', + includeBotMessages: 'Also messages from apps', + includeBotMessagesTooltip: + 'Leave off unless needed. The messages this plugin posts itself come back too, and a process that answers them would keep itself running.', }, de: { title: 'Slack', 'post-message': 'Kommentar posten', 'post-message-with-file': 'Kommentar mit Datei posten', + 'receive-message': 'Nachricht empfangen', url: 'Slack URL', urlTooltip: 'Die URL zur REST API von Slack', - description: 'Veröffentlichen Sie Nachrichten mit dem Slack-Plugin.', + description: + 'Veröffentlichen Sie Nachrichten mit dem Slack-Plugin und starten oder setzen Sie einen Prozess anhand einer Slack-Nachricht fort.', configurationTitle: 'Konfigurationsname', configurationTitleTooltip: 'Der Name der aktuellen Plugin-Konfiguration. Unter diesem Namen ist die Konfiguration im Rest der Anwendung zu finden.', token: 'Token', - tokenTooltip: 'Authentifizierungstoken mit erforderlichen scopes.', + tokenTooltip: + 'Authentifizierungstoken mit erforderlichen scopes. Zum Empfangen wird channels:history benötigt, und die App muss Mitglied des Kanals sein.', channel: 'Channel', channelTooltip: 'Kanal, private Gruppe oder IM-Kanal, an den die Nachricht gesendet werden soll. Kann eine codierte ID oder ein Name sein. Siehe unten für weitere Details.', @@ -97,6 +174,40 @@ const slackPluginSpecification: PluginSpecification = { messageTooltip: 'Der Nachrichtentext.', filename: 'Dateiname', filenameTooltip: 'Der Dateiname der Datei.', + threadTs: 'Thread', + threadTsTooltip: + 'Leer lassen, um eine neue Nachricht im Kanal zu posten. Mit dem Zeitstempel einer früheren Nachricht wird innerhalb dieses Threads geantwortet, zum Beispiel pv:slackThreadTs.', + + initialLookbackMinutes: 'Rückblick beim ersten Lauf (Minuten)', + initialLookbackMinutesTooltip: + 'Wie viele Minuten Verlauf der erste Lauf eines Kanals berücksichtigt. Alles Ältere wird nie gelesen, damit ein bestehender Kanal nicht rückwirkend Fälle erzeugt.', + messagesPerPage: 'Nachrichten pro Seite', + messagesPerPageTooltip: 'Wie viele Nachrichten pro Anfrage an Slack gelesen werden.', + maxPagesPerPoll: 'Maximale Seiten pro Lauf', + maxPagesPerPollTooltip: + 'Begrenzt, wie viele Seiten eines Kanals pro Lauf gelesen werden. Wird die Grenze erreicht, erscheint eine Warnung im Log.', + maxThreadsPerPoll: 'Maximale Threads pro Lauf', + maxThreadsPerPollTooltip: + 'Slack kostet eine Anfrage pro Thread, in dem ein Fall wartet. Diese Grenze verhindert, dass viele wartende Fälle das Lesen des Kanals selbst verdrängen.', + + receiveMessageDescription: + 'Startet oder setzt diesen Prozess für jede Nachricht im angegebenen Kanal fort. Nur einen Kanal angeben, um alle Nachrichten zu verarbeiten, oder einen Filter hinzufügen, damit dieser Prozess nur einen Teil davon aufgreift.', + receiveChannel: 'Kanal-ID', + receiveChannelTooltip: + 'Die ID des zu lesenden Kanals, zum Beispiel C012AB3CD. Ein #Name funktioniert hier nicht: Slack liefert den Verlauf nur per ID.', + messageContains: 'Nachricht enthält', + messageContainsTooltip: 'Filtert auf einen Teil des Nachrichtentexts.', + userId: 'Benutzer-ID', + userIdTooltip: 'Filtert auf den Slack-Benutzer, der die Nachricht gepostet hat, zum Beispiel U012AB3CD.', + threadScope: 'Nachrichten in Threads', + threadScopeTooltip: + 'Nützlich, wenn ein Kanal zwei Schritte versorgt: ein Startereignis nur für neue Gespräche und ein Zwischenereignis nur für die Antworten darauf.', + 'threadScope.ANY': 'Alle Nachrichten', + 'threadScope.THREAD_STARTS_ONLY': 'Nur Nachrichten, die ein Gespräch beginnen', + 'threadScope.THREAD_REPLIES_ONLY': 'Nur Antworten in einem Thread', + includeBotMessages: 'Auch Nachrichten von Apps', + includeBotMessagesTooltip: + 'Ausgeschaltet lassen, sofern nicht benötigt. Die von diesem Plugin selbst geposteten Nachrichten kommen ebenfalls zurück, und ein Prozess, der darauf antwortet, würde sich selbst am Laufen halten.', }, }, }; diff --git a/frontend/projects/plugin/src/public_api.ts b/frontend/projects/plugin/src/public_api.ts index 1ee9cee..f54e157 100644 --- a/frontend/projects/plugin/src/public_api.ts +++ b/frontend/projects/plugin/src/public_api.ts @@ -24,3 +24,4 @@ export * from './lib/slack-plugin.specification'; export * from './lib/components/slack-configuration/slack-configuration.component'; export * from './lib/components/post-message/post-message-configuration.component'; export * from './lib/components/post-message-with-file/post-message-with-file-configuration.component'; +export * from './lib/components/receive-message/receive-message-configuration.component';