From c0eab35fde7d2c13a87be3e415ecd56dfdc7d28d Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:30:37 -0500 Subject: [PATCH 1/2] feat(outbox): FILE messages via Slack's external upload flow files.getUploadURLExternal, a streamed upload from core storage (no temp files), files.completeUploadExternal; expired-lease reconcile by title marker through files.list; files:read scope added to the manifest and design. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 4 +- docs/ddl/slack-postgres.sql | 2 + docs/slack-app-manifest.yaml | 2 + .../qbits/slack/delivery/SlackDispatcher.java | 108 +++++++++++++++++- .../qbits/slack/metadata/SlackTables.java | 4 +- .../qbits/slack/oauth/SlackConnect.java | 4 +- .../qbits/slack/outbox/SlackSendRequest.java | 26 +++++ .../qbits/slack/outbox/SlackService.java | 64 ++++++++++- .../qbits/slack/transport/SlackClient.java | 68 +++++++++++ .../qbits/slack/FakeSlackServer.java | 35 +++++- .../qbits/slack/SlackAuthorizerTest.java | 6 +- src/test/resources/slack-h2.sql | 2 + 12 files changed, 310 insertions(+), 15 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 87c5803..ee10e11 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -165,8 +165,8 @@ App-level secrets are named environment variables, allow-listed in `allowedEnvNa | Kind | Scopes | |---|---| -| Bot | `chat:write`, `im:write`, `channels:read`, `groups:read`, `users:read`, `files:write`, `usergroups:read` (optional), `chat:write.customize` (optional) | -| User | `chat:write`, `channels:read`, `groups:read`, `im:write`, `files:write`, `users:read` | +| Bot | `chat:write`, `im:write`, `channels:read`, `groups:read`, `users:read`, `files:write`, `files:read` (file reconcile), `usergroups:read` (optional), `chat:write.customize` (optional) | +| User | `chat:write`, `channels:read`, `groups:read`, `im:write`, `files:write`, `files:read`, `users:read` | - **Redirect URL:** `https:///qqq/hooks/slack/oauth/callback`. - **Interactivity URL (phase 2):** `https:///qqq/hooks/slack/interactions`. diff --git a/docs/ddl/slack-postgres.sql b/docs/ddl/slack-postgres.sql index 80d901b..ae060ae 100644 --- a/docs/ddl/slack-postgres.sql +++ b/docs/ddl/slack-postgres.sql @@ -120,6 +120,8 @@ CREATE TABLE slack_message ( thread_ts VARCHAR(500), target_message_id INTEGER, attachment_storage_ref VARCHAR(500), + attachment_storage_table VARCHAR(500), + file_reconcile_required BOOLEAN DEFAULT FALSE, attachment_name VARCHAR(500), attachment_bytes BIGINT, slack_file_id VARCHAR(500), diff --git a/docs/slack-app-manifest.yaml b/docs/slack-app-manifest.yaml index 7227160..7b23019 100644 --- a/docs/slack-app-manifest.yaml +++ b/docs/slack-app-manifest.yaml @@ -18,6 +18,7 @@ oauth_config: - groups:read - users:read - files:write + - files:read - usergroups:read - chat:write.customize user: @@ -26,6 +27,7 @@ oauth_config: - groups:read - im:write - files:write + - files:read - users:read settings: interactivity: diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDispatcher.java b/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDispatcher.java index 943aad1..3125ce8 100644 --- a/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDispatcher.java +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDispatcher.java @@ -387,11 +387,6 @@ public class SlackDispatcher String owner = message.getValueString("ownerUserId"); boolean update = SlackEnums.Operation.UPDATE.name().equals(message.getValueString("operation")); - if(SlackEnums.Operation.FILE.name().equals(message.getValueString("operation"))) - { - return (new Outcome(Result.PERMANENT, "unsupported", "File messages aren't supported yet", null, null, null, null, null, null, false)); - } - /////////////////////////////////////////////////////////////////////////////// // authorize again, as the owner: access may have changed since queue time. // // the posting identity chosen at queue time is kept (an update must keep it, // @@ -435,6 +430,11 @@ public class SlackDispatcher channel = open.getJson().getJSONObject("channel").optString("id", null); } + if(SlackEnums.Operation.FILE.name().equals(message.getValueString("operation"))) + { + return (file(client, token, channel, claim, send)); + } + if(claim.reconcile() && !update) { Outcome reconciled = reconcile(client, token, channel, message, send); @@ -456,6 +456,104 @@ public class SlackDispatcher return (new Outcome(Result.SENT, null, null, response.getHttpStatus(), null, ts, response.getString("channel") == null ? channel : response.getString("channel"), send.postAs(), send.connectionId(), false)); } + /******************************************************************************* + ** Stream storage as the owner, then complete the external upload. Once an + ** upload id exists, retries only reconcile: a lost completion response must + ** never create a second file. Unconfirmed uploads remain RETRY_WAIT. + *******************************************************************************/ + private Outcome file(SlackClient client, String token, String channel, Claim claim, AuthorizedSend send) throws QException + { + QRecord message = claim.message(); + Integer id = message.getValueInteger("id"); + if(claim.reconcile() || Boolean.TRUE.equals(message.getValueBoolean("fileReconcileRequired")) || message.getValueString("slackFileId") != null) + { + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("fileReconcileRequired", true), null); + return (reconcileFile(client, token, channel, message, send)); + } + Long size = message.getValueLong("attachmentBytes"); + if(size == null || size <= 0 || size > SlackRuntime.config().getMaxAttachmentBytes()) + { + return (new Outcome(Result.PERMANENT, "file_size", "The attachment exceeds the Slack size limit", null, null, null, channel, send.postAs(), send.connectionId(), false)); + } + return (com.kingsrook.qbits.slack.security.SlackOwnerSession.call(message.getValueString("ownerUserId"), () -> + { + try(java.io.InputStream bytes = new com.kingsrook.qqq.backend.core.actions.tables.StorageAction().getInputStream( + new com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput(message.getValueString("attachmentStorageTable")) + .withReference(message.getValueString("attachmentStorageRef")))) + { + SlackResponse allocated = client.filesGetUploadURLExternal(token, message.getValueString("attachmentName"), size); + if(!allocated.isOk()) + { + return (failure(allocated, send, channel)); + } + String fileId = allocated.getString("file_id"); + String url = allocated.getString("upload_url"); + if(fileId == null || url == null) + { + return (failure(SlackResponse.failure(0, "invalid_upload_response"), send, channel)); + } + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("slackFileId", fileId), null); + SlackResponse uploaded = client.uploadExternal(url, bytes, size); + if(!uploaded.isOk()) + { + return (failure(uploaded, send, channel)); + } + SlackResponse completed = client.filesCompleteUploadExternal(token, fileId, + message.getValueString("attachmentName") + " qqq:" + id, channel, message.getValueString("threadTs")); + if(!completed.isOk()) + { + return (failure(completed, send, channel)); + } + return (new Outcome(Result.SENT, null, null, completed.getHttpStatus(), null, null, channel, send.postAs(), send.connectionId(), false)); + } + catch(java.io.IOException e) + { + return (failure(SlackResponse.failure(0, "storage_read_failed"), send, channel)); + } + })); + } + + /******************************************************************************* + ** A missing marker is inconclusive, including when Slack is still processing + ** the upload. Every retry continues reconciliation instead of uploading again. + *******************************************************************************/ + private Outcome reconcileFile(SlackClient client, String token, String channel, QRecord message, AuthorizedSend send) throws QException + { + var attempts = SlackStore.query(SlackTables.ATTEMPT, SlackStore.equal("messageId", message.getValueInteger("id")) + .withOrderBy(new QFilterOrderBy("startDate")).withLimit(1)); + Instant since = attempts.isEmpty() ? null : attempts.getFirst().getValueInstant("startDate"); + String oldest = since == null ? "0" : String.valueOf(since.getEpochSecond() - HISTORY_LOOKBACK_SECONDS); + for(int page = 1; page <= MAX_HISTORY_PAGES; page++) + { + SlackResponse response = client.filesList(token, channel, oldest, page); + if(!response.isOk()) + { + if(SlackErrorClassifier.classify(response) == SlackErrorClassifier.Kind.AUTH + || SlackErrorClassifier.classify(response) == SlackErrorClassifier.Kind.RATE_LIMITED) + { + return (failure(response, send, channel)); + } + break; + } + JSONArray files = response.getJson().optJSONArray("files"); + for(int i = 0; files != null && i < files.length(); i++) + { + JSONObject found = files.getJSONObject(i); + if(found.optString("title").endsWith(" qqq:" + message.getValueInteger("id")) && !found.optString("id").isBlank()) + { + SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", message.getValueInteger("id")).withValue("slackFileId", found.getString("id")), null); + return (new Outcome(Result.SENT, null, null, response.getHttpStatus(), null, null, channel, send.postAs(), send.connectionId(), false)); + } + } + JSONObject paging = response.getJson().optJSONObject("paging"); + if(paging == null || page >= paging.optInt("pages", 1)) + { + break; + } + } + return (new Outcome(Result.TRANSIENT, "file_unconfirmed", "Slack hasn't confirmed the earlier file upload", null, null, null, channel, send.postAs(), send.connectionId(), false)); + } + /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java index 4f8cde5..aad1779 100644 --- a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java +++ b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java @@ -459,9 +459,11 @@ public final class SlackTables .withField(field("threadTs", QFieldType.STRING).withMaxLength(500)) .withField(field("targetMessageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE)) .withField(field("attachmentStorageRef", QFieldType.STRING).withMaxLength(500)) + .withField(field("attachmentStorageTable", QFieldType.STRING).withMaxLength(500)) .withField(field("attachmentName", QFieldType.STRING).withMaxLength(500)) .withField(field("attachmentBytes", QFieldType.LONG)) .withField(field("slackFileId", QFieldType.STRING).withMaxLength(500)) + .withField(field("fileReconcileRequired", QFieldType.BOOLEAN).withDefaultValue(false)) .withField(field("slackTs", QFieldType.STRING).withMaxLength(500)) .withField(field("permalink", QFieldType.TEXT)) .withField(field("attemptCount", QFieldType.INTEGER).withDefaultValue(0)) @@ -477,7 +479,7 @@ public final class SlackTables .withField(field("deliveryLogId", QFieldType.STRING).withMaxLength(500)); table.withUniqueKey(new UniqueKey("idempotencyKey")); table.withSection(SectionFactory.defaultT1("id", "status", "ownerUserId", "workspaceId", "targetType", "channelLabel")); - table.withSection(SectionFactory.defaultT2("connectionId", "postAs", "operation", "targetRef", "slackChannelId", "category", "templateName", "text", "blocksJson", "variablesSnapshot", "idempotencyKey", "parentMessageId", "threadTs", "targetMessageId", "attachmentStorageRef", "attachmentName", "attachmentBytes", "slackFileId", "slackTs", "permalink", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastErrorCode", "lastError", "sentDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderName", "deliveryLogId")); + table.withSection(SectionFactory.defaultT2("connectionId", "postAs", "operation", "targetRef", "slackChannelId", "category", "templateName", "text", "blocksJson", "variablesSnapshot", "idempotencyKey", "parentMessageId", "threadTs", "targetMessageId", "attachmentStorageTable", "attachmentStorageRef", "attachmentName", "attachmentBytes", "slackFileId", "fileReconcileRequired", "slackTs", "permalink", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastErrorCode", "lastError", "sentDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderName", "deliveryLogId")); table.withSection(SectionFactory.defaultT3("createDate", "modifyDate")); table.withSection(SectionFactory.customT2("attempts", new QIcon("list")).withLabel("Attempts").withWidgetName(childWidgetName(ATTEMPT))); return (table); diff --git a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java index f9a931a..9a85d92 100644 --- a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java +++ b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java @@ -47,8 +47,8 @@ import org.json.JSONObject; *******************************************************************************/ public final class SlackConnect { - public static final String BOT_SCOPES = "chat:write,im:write,channels:read,groups:read,users:read,files:write"; - public static final String USER_SCOPES = "chat:write,channels:read,groups:read,im:write,files:write,users:read"; + public static final String BOT_SCOPES = "chat:write,im:write,channels:read,groups:read,users:read,files:write,files:read"; + public static final String USER_SCOPES = "chat:write,channels:read,groups:read,im:write,files:write,files:read,users:read"; /******************************************************************************* ** Utility class. diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java index 535f543..cbaab1a 100644 --- a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackSendRequest.java @@ -32,6 +32,32 @@ import com.kingsrook.qbits.slack.metadata.SlackEnums; *******************************************************************************/ public class SlackSendRequest { + /******************************************************************************* + ** A storage reference to stream later, in the owner's session. + *******************************************************************************/ + public record Attachment(String table, String reference, String name, Long bytes) + { + } + + private java.util.List attachments = java.util.List.of(); + + /******************************************************************************* + ** Attachments are persisted atomically with the parent post. + *******************************************************************************/ + public java.util.List getAttachments() + { + return (attachments); + } + + /******************************************************************************* + ** Supply immutable attachment descriptors. + *******************************************************************************/ + public SlackSendRequest withAttachments(java.util.List value) + { + attachments = java.util.List.copyOf(value); + return (this); + } + private String templateName = null; private java.util.Map variables = java.util.Map.of(); private String contentClassification = null; diff --git a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java index 061f94b..1a48bee 100644 --- a/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java +++ b/src/main/java/com/kingsrook/qbits/slack/outbox/SlackService.java @@ -101,6 +101,23 @@ public final class SlackService return (SlackSendResult.refused(problem)); } + for(SlackSendRequest.Attachment attachment : request.getAttachments()) + { + if(attachment.table() == null || attachment.reference() == null || attachment.name() == null + || attachment.bytes() == null || attachment.bytes() <= 0 || attachment.bytes() > config.getMaxAttachmentBytes()) + { + return (SlackSendResult.refused("An attachment is missing its storage reference or exceeds the Slack size limit")); + } + try + { + com.kingsrook.qbits.slack.phi.SlackPayloadGuard.text(attachment.name(), request.getContentClassification()); + } + catch(IllegalArgumentException e) + { + return (SlackSendResult.refused("Invalid or restricted Slack attachment")); + } + } + /////////////////////////////////////////////////////////////////////// // who is sending: a user sends only as themselves // /////////////////////////////////////////////////////////////////////// @@ -177,7 +194,52 @@ public final class SlackService row.withValue("nextAttemptDate", null); } } - return (insert(row)); + return (request.getAttachments().isEmpty() ? insert(row) : insertDelivery(row, request.getAttachments())); + } + + /******************************************************************************* + ** Commit a post and its waiting files together. A duplicate key never adds + ** children to an earlier delivery; a failed child rolls back the whole set. + *******************************************************************************/ + private static SlackSendResult insertDelivery(QRecord row, List attachments) throws QException + { + try(var tx = com.kingsrook.qqq.backend.core.actions.QBackendTransaction.openFor( + new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(SlackTables.MESSAGE))) + { + QRecord parent = SlackStore.insert(SlackTables.MESSAGE, row, tx); + if(parent.getValueInteger("id") == null || parent.getErrors() != null && !parent.getErrors().isEmpty()) + { + throw (new QException("Could not queue Slack delivery")); + } + for(SlackSendRequest.Attachment attachment : attachments) + { + QRecord file = new QRecord(); + for(String field : List.of("ownerUserId", "workspaceId", "connectionId", "postAs", "targetType", "targetRef", "slackChannelId", "channelLabel")) + { + file.withValue(field, row.getValue(field)); + } + file.withValue("operation", "FILE").withValue("status", "WAITING_PARENT").withValue("attemptCount", 0) + .withValue("parentMessageId", parent.getValueInteger("id")) + .withValue("attachmentStorageTable", attachment.table()).withValue("attachmentStorageRef", attachment.reference()) + .withValue("attachmentName", attachment.name()).withValue("attachmentBytes", attachment.bytes()); + QRecord inserted = SlackStore.insert(SlackTables.MESSAGE, file, tx); + if(inserted.getValueInteger("id") == null || inserted.getErrors() != null && !inserted.getErrors().isEmpty()) + { + throw (new QException("Could not queue Slack attachment")); + } + } + tx.commit(); + return (new SlackSendResult(SlackSendResult.Status.QUEUED, parent.getValueInteger("id"), List.of())); + } + catch(QException e) + { + SlackSendResult existing = duplicate(row.getValueString("idempotencyKey"), row.getValueString("ownerUserId")); + if(existing != null) + { + return (existing); + } + throw (new QException("Could not queue Slack delivery")); + } } diff --git a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java index af52d02..6612ea2 100644 --- a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java +++ b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java @@ -52,6 +52,74 @@ public class SlackClient { public static final String EVENT_TYPE = "qqq_message"; + /******************************************************************************* + ** Obtain a one-use external upload URL for an exact byte length. + *******************************************************************************/ + public SlackResponse filesGetUploadURLExternal(String token, String filename, long length) + { + return (post(token, "files.getUploadURLExternal", new JSONObject().put("filename", filename).put("length", length))); + } + + /******************************************************************************* + ** Stream raw bytes without staging or forwarding the API bearer token. + ** Only Slack's upload host (or the configured test origin) is accepted. + *******************************************************************************/ + public SlackResponse uploadExternal(String url, java.io.InputStream bytes, long length) + { + try + { + URI uri = URI.create(url); + URI api = URI.create(baseUrl); + boolean slack = "https".equals(uri.getScheme()) && "files.slack.com".equals(uri.getHost()) && uri.getPort() == -1; + boolean configured = java.util.Objects.equals(uri.getScheme(), api.getScheme()) + && java.util.Objects.equals(uri.getHost(), api.getHost()) && uri.getPort() == api.getPort(); + if(uri.getUserInfo() != null || !(slack || configured)) + { + return (SlackResponse.failure(400, "invalid_upload_url")); + } + var publisher = HttpRequest.BodyPublishers.fromPublisher(HttpRequest.BodyPublishers.ofInputStream(() -> bytes), length); + var response = http.send(HttpRequest.newBuilder(uri).timeout(requestTimeout).header("Content-Type", "application/octet-stream") + .POST(publisher).build(), HttpResponse.BodyHandlers.discarding()); + int status = response.statusCode(); + return (new SlackResponse(status, status == 200, status == 200 ? null : "http_" + status, + status == 429 ? response.headers().firstValue("Retry-After").map(SlackClient::parseSeconds).orElse(DEFAULT_RETRY_AFTER_SECONDS) : null, null, null)); + } + catch(IOException | IllegalArgumentException e) + { + return (SlackResponse.failure(0, "upload_failed")); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + return (SlackResponse.failure(0, "interrupted")); + } + } + + /******************************************************************************* + ** Share the uploaded file, with a durable reconciliation marker in its title. + *******************************************************************************/ + public SlackResponse filesCompleteUploadExternal(String token, String fileId, String title, String channel, String threadTs) + { + JSONObject body = new JSONObject().put("files", new JSONArray().put(new JSONObject().put("id", fileId).put("title", title))); + if(channel != null) + { + body.put("channel_id", channel); + } + if(threadTs != null) + { + body.put("thread_ts", threadTs); + } + return (post(token, "files.completeUploadExternal", body)); + } + + /******************************************************************************* + ** List channel files since the first claim; files.list uses numbered pages. + *******************************************************************************/ + public SlackResponse filesList(String token, String channel, String since, int page) + { + return (get(token, "files.list", Map.of("channel", channel, "ts_from", since, "count", "100", "page", String.valueOf(page)))); + } + private static final int DEFAULT_RETRY_AFTER_SECONDS = 30; private final String baseUrl; diff --git a/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java b/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java index f4b4c89..c22f913 100644 --- a/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java +++ b/src/test/java/com/kingsrook/qbits/slack/FakeSlackServer.java @@ -51,6 +51,16 @@ import org.json.JSONObject; public class FakeSlackServer implements AutoCloseable { private final HttpServer server; + private final Map uploads = new ConcurrentHashMap<>(); + private final Map files = new ConcurrentHashMap<>(); + + /******************************************************************************* + ** Exact raw upload bytes, including non-UTF8 content. + *******************************************************************************/ + public byte[] uploaded(String fileId) + { + return (uploads.get(fileId)); + } private final List requests = new CopyOnWriteArrayList<>(); private final Map> scripts = new ConcurrentHashMap<>(); @@ -227,7 +237,12 @@ public class FakeSlackServer implements AutoCloseable private void handle(HttpExchange exchange) throws IOException { String method = exchange.getRequestURI().getPath().substring("/api/".length()); - String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + byte[] raw = exchange.getRequestBody().readAllBytes(); + String body = new String(raw, StandardCharsets.UTF_8); + if(method.startsWith("upload/")) + { + uploads.put(method.substring("upload/".length()), raw); + } String query = exchange.getRequestURI().getRawQuery(); Recorded recorded = new Recorded(method, exchange.getRequestHeaders().getFirst("Authorization"), query, body); requests.add(recorded); @@ -295,6 +310,24 @@ public class FakeSlackServer implements AutoCloseable switch(request.method()) { + case "files.getUploadURLExternal": + String fileId = "F" + tsCount.incrementAndGet(); + return (Reply.of(200, new JSONObject().put("ok", true).put("file_id", fileId).put("upload_url", baseUrl() + "/upload/" + fileId).toString())); + case "files.completeUploadExternal": + JSONObject body = request.json(); + JSONArray completed = body.getJSONArray("files"); + JSONArray channelFiles = files.computeIfAbsent(body.optString("channel_id"), k -> new JSONArray()); + for(int i = 0; i < completed.length(); i++) + { + channelFiles.put(completed.getJSONObject(i)); + } + return (Reply.of(200, new JSONObject().put("ok", true).put("files", completed).toString())); + case "files.list": + return (Reply.of(200, new JSONObject().put("ok", true).put("files", files.getOrDefault(query.get("channel"), new JSONArray())).toString())); + case "users.conversations": + JSONArray channels = new JSONArray(); + names.forEach((id, name) -> channels.put(new JSONObject().put("id", id).put("name", name))); + return (Reply.of(200, new JSONObject().put("ok", true).put("channels", channels).toString())); case "oauth.v2.access": return (Reply.error("invalid_code")); case "auth.revoke": diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackAuthorizerTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackAuthorizerTest.java index 468836b..e9e1439 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackAuthorizerTest.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackAuthorizerTest.java @@ -359,8 +359,8 @@ class SlackAuthorizerTest /******************************************************************************* ** The dispatcher's other entry points: sendNow ignores the due time, an - ** unknown id does nothing, and a FILE message is a permanent failure until - ** file delivery exists. + ** unknown id does nothing, and a FILE message without attachment details + ** is a permanent validation failure. *******************************************************************************/ @Test void testSendNowUnknownIdAndFileMessage() throws Exception @@ -377,6 +377,6 @@ class SlackAuthorizerTest env.set(SlackTables.MESSAGE, file.messageId(), "operation", "FILE"); env.dispatch(); assertThat(env.row(SlackTables.MESSAGE, file.messageId()).getValueString("status")).isEqualTo("DEAD_LETTER"); - assertThat(env.row(SlackTables.MESSAGE, file.messageId()).getValueString("lastErrorCode")).isEqualTo("unsupported"); + assertThat(env.row(SlackTables.MESSAGE, file.messageId()).getValueString("lastErrorCode")).isEqualTo("file_size"); } } diff --git a/src/test/resources/slack-h2.sql b/src/test/resources/slack-h2.sql index a621ebf..288af2a 100644 --- a/src/test/resources/slack-h2.sql +++ b/src/test/resources/slack-h2.sql @@ -122,6 +122,8 @@ CREATE TABLE slack_message ( thread_ts VARCHAR(500), target_message_id INTEGER, attachment_storage_ref VARCHAR(500), + attachment_storage_table VARCHAR(500), + file_reconcile_required BOOLEAN DEFAULT FALSE, attachment_name VARCHAR(500), attachment_bytes BIGINT, slack_file_id VARCHAR(500), -- 2.54.0 From 8d5fa6d462d68f83c1043d3610fd468e955f3570 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 13:30:38 -0500 Subject: [PATCH 2/2] feat(delivery): Slack delivery destination and SlackApi.applySessionKeys SlackDeliveryDestination: contingent pickers (workspace, then DM to me or a channel the user is a member of), owner re-authorization, delivery template, attachments as FILE messages, lookup mapping; built against STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) copies of the core delivery types. SlackApi.applySessionKeys so hosts stop using internal SlackSecurity. Co-Authored-By: Claude Opus 5.5 --- .../qbits/slack/SlackQBitProducer.java | 1 + .../kingsrook/qbits/slack/api/SlackApi.java | 11 + .../delivery/SlackDeliveryDestination.java | 251 ++++++++++ .../delivery/standin/DeliveryAttachment.java | 193 ++++++++ .../standin/DeliveryDestinationContext.java | 65 +++ .../standin/DeliveryDestinationInterface.java | 69 +++ .../delivery/standin/DeliveryRequest.java | 453 ++++++++++++++++++ .../delivery/standin/DeliveryResult.java | 139 ++++++ .../standin/DeliveryTargetOption.java | 129 +++++ .../delivery/standin/DeliveryTargetStep.java | 226 +++++++++ .../standin/QDeliveryDestinationMetaData.java | 396 +++++++++++++++ .../qbits/slack/template/SlackTemplates.java | 7 +- .../slack/SlackDeliveryDestinationTest.java | 327 +++++++++++++ 13 files changed, 2266 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/SlackDeliveryDestination.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryAttachment.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryDestinationContext.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryDestinationInterface.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryRequest.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryResult.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryTargetOption.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryTargetStep.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/delivery/standin/QDeliveryDestinationMetaData.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackDeliveryDestinationTest.java diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index b3ed239..1e5689e 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -147,6 +147,7 @@ public class SlackQBitProducer implements QBitMetaDataProducer output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName())); output.addSelfToInstance(qInstance); + com.kingsrook.qbits.slack.delivery.SlackDeliveryDestination.register(); qInstance.addApp(com.kingsrook.qbits.slack.app.SlackApp.app(qInstance.getTables())); com.kingsrook.qbits.slack.app.SlackApp.widgets().forEach(qInstance::addWidget); com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter.register(qInstance); diff --git a/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java b/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java index 6cc790a..4d19cf1 100644 --- a/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java +++ b/src/main/java/com/kingsrook/qbits/slack/api/SlackApi.java @@ -53,6 +53,17 @@ import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; *******************************************************************************/ public final class SlackApi { + /******************************************************************************* + ** Apply Slack owner and body-permission keys to a host-created session. + ** Idempotently replaces earlier Slack keys. Null is a no-op; a missing user + ** gets no owner key. Hosts must authorize allAccess before passing true. + *******************************************************************************/ + public static void applySessionKeys(com.kingsrook.qqq.backend.core.model.session.QSession session, boolean allAccess) + { + com.kingsrook.qbits.slack.security.SlackSecurity.applyToSession(session, + session == null || session.getUser() == null ? null : session.getUser().getIdReference(), allAccess); + } + /******************************************************************************* ** Utility class. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDeliveryDestination.java b/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDeliveryDestination.java new file mode 100644 index 0000000..d9372df --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/SlackDeliveryDestination.java @@ -0,0 +1,251 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import com.kingsrook.qbits.slack.api.SlackApi; +import com.kingsrook.qbits.slack.api.SlackApiMessage; +import com.kingsrook.qbits.slack.delivery.standin.DeliveryDestinationContext; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.DeliveryDestinationInterface; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.DeliveryRequest; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.DeliveryResult; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.DeliveryTargetOption; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.DeliveryTargetStep; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.QDeliveryDestinationMetaData; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.security.SlackAuthorizer; +import com.kingsrook.qbits.slack.security.SlackOwnerSession; +import com.kingsrook.qbits.slack.support.SlackRuntime; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; + +/******************************************************************************* + ** Host-callable Slack output destination until core's delivery SPI is released. + ** Targets are workspaceId and where (me or a channel address from SlackApi). + *******************************************************************************/ +public class SlackDeliveryDestination implements DeliveryDestinationInterface +{ + /******************************************************************************* + ** Describe the policy hosts must apply before invoking this destination. + *******************************************************************************/ + public QDeliveryDestinationMetaData metadata() + { + return (new QDeliveryDestinationMetaData().withName("slack").withLabel("Slack") + .withPermissionName("slack.send").withCodeReference(new QCodeReference(SlackDeliveryDestination.class)) + .withMaxClassification(DataClassification.CONFIDENTIAL) + .withAboveMaxClassification(QDeliveryDestinationMetaData.AboveMaxClassification.REFUSE) + .withMaxAttachmentBytes(SlackRuntime.config().getMaxAttachmentBytes()).withAllowBearerLinks(false)); + } + + /******************************************************************************* + ** Detect the real SPI without registering an incompatible temporary instance. + *******************************************************************************/ + public static void register() + { + try + { + Class.forName("com.kingsrook.qqq.backend.core.delivery.DeliveryDestinationsMetaData"); + com.kingsrook.qqq.backend.core.logging.QLogger.getLogger(SlackDeliveryDestination.class) + .info("Slack delivery stand-in will be replaced after RC.8; host registration is deferred"); // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + } + catch(ClassNotFoundException e) + { + // RC.6 hosts use this destination directly. + } + } + + /******************************************************************************* + ** Offer only active connections and membership-checked targets of this user. + *******************************************************************************/ + @Override + public DeliveryTargetStep describeTargets(DeliveryDestinationContext context, Map chosen) throws QException + { + String user = QContext.getQSession().getUser().getIdReference(); + var connections = SlackApi.connectionsFor(user).stream().filter(c -> "ACTIVE".equals(c.status())).toList(); + Integer workspace = workspace(chosen); + var connection = connections.stream().filter(c -> c.workspaceId().equals(workspace)).findFirst().orElse(null); + if(connection == null) + { + return (new DeliveryTargetStep().withName("workspaceId").withLabel("Workspace") + .withOptions(connections.stream().map(c -> new DeliveryTargetOption().withValue(c.workspaceId().toString()).withLabel(c.workspaceLabel())).toList())); + } + var targets = SlackApi.listTargets(user, workspace); + String where = chosen == null ? null : (String) chosen.get("where"); + var target = targets.stream().filter(t -> t.address().equals(where)).findFirst().orElse(null); + return (new DeliveryTargetStep().withName("where").withLabel("Where") + .withOptions(targets.stream().map(t -> new DeliveryTargetOption().withValue(t.address()).withLabel(t.label())).toList()) + .withComplete(target != null).withSummary(target == null ? null : + "Slack: " + ("me".equals(where) ? "DM to me" : "#" + target.label().replaceFirst("^#", "")) + " in " + connection.workspaceLabel())); + } + + /******************************************************************************* + ** Recheck owner identity, connection and membership without queueing anything. + *******************************************************************************/ + @Override + public List validate(DeliveryRequest request) throws QException + { + if(request == null || request.getOwnerUserId() == null || request.getOwnerUserId().isBlank()) + { + return (List.of("A delivery owner is required")); + } + var session = QContext.getQSession(); + if(session == null || !(session instanceof QSystemUserSession) + && (session.getUser() == null || !request.getOwnerUserId().equals(session.getUser().getIdReference()))) + { + return (List.of("Slack deliveries can only be sent as the signed-in user")); + } + Integer workspace = workspace(request.getTargetValues()); + String where = request.getTargetValues() == null ? null : (String) request.getTargetValues().get("where"); + if(workspace == null || where == null || !("me".equals(where) || where.startsWith("channel:" + workspace + "/"))) + { + return (List.of("Choose a workspace and a destination")); + } + try + { + if(com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer.isInstanceEnforcementEnabled() + && !SlackOwnerSession.create(QContext.getQInstance(), request.getOwnerUserId()).hasPermission("slack.send")) + { + return (List.of("Sending to Slack needs the slack.send permission")); + } + } + catch(QException e) + { + return (List.of("Slack couldn't verify your access")); + } + var result = new SlackAuthorizer().authorize(new SlackAuthorizer.Request() + .withOwnerUserId(request.getOwnerUserId()).withWorkspaceId(workspace).withAddress(where)); + return (result.isAuthorized() ? List.of() : List.of(result.getRefusal().message())); + } + + /******************************************************************************* + ** Queue text and storage references atomically, after owner authorization. + *******************************************************************************/ + @Override + public DeliveryResult deliver(DeliveryRequest request) throws QException + { + List denied = validate(request); + if(!denied.isEmpty()) + { + return (new DeliveryResult().withStatus(DeliveryResult.Status.UNAUTHORIZED).withReasons(denied)); + } + if(request.getIdempotencyKey() == null || request.getIdempotencyKey().isBlank()) + { + return (new DeliveryResult().withStatus(DeliveryResult.Status.REFUSED).withReasons(List.of("An idempotency key is required"))); + } + return (SlackOwnerSession.call(request.getOwnerUserId(), () -> + { + Map variables = new LinkedHashMap<>(); + variables.put("title", request.getTitle()); + variables.put("body", request.getSummary()); + variables.put("summary", request.getSummary()); + variables.put("link", request.getLink()); + variables.put("source", request.getSourceType()); + variables.put("runTime", SlackRuntime.clock().instant().toString()); + SlackSendRequest send = new SlackSendRequest().withOwnerUserId(request.getOwnerUserId()) + .withWorkspaceId(workspace(request.getTargetValues())).withTo((String) request.getTargetValues().get("where")) + .withTemplateName("delivery").withVariables(variables).withIdempotencyKey(request.getIdempotencyKey()) + .withContentClassification(request.getContentClassification() == null ? null : request.getContentClassification().name()) + .withAttachments(request.getAttachments().stream().map(a -> new SlackSendRequest.Attachment( + a.getStorageTableName(), a.getStorageReference(), a.getFileName(), a.getSize())).toList()); + SlackSendResult result = SlackService.enqueue(send); + if(result.status() == SlackSendResult.Status.REFUSED) + { + List changedAccess = validate(request); + if(!changedAccess.isEmpty()) + { + return (new DeliveryResult().withStatus(DeliveryResult.Status.UNAUTHORIZED).withReasons(changedAccess)); + } + return (new DeliveryResult().withStatus(DeliveryResult.Status.REFUSED).withReasons(result.reasons())); + } + return (lookup(request.getIdempotencyKey()).orElseGet(() -> new DeliveryResult() + .withStatus(DeliveryResult.Status.QUEUED).withExternalReference(String.valueOf(result.messageId())))); + })); + } + + /******************************************************************************* + ** Reconcile only visible messages, including every attachment of a delivery. + *******************************************************************************/ + @Override + public Optional lookup(String key) throws QException + { + Optional found = SlackApi.getMessageByIdempotencyKey(key); + if(found.isEmpty()) + { + return (Optional.empty()); + } + SlackApiMessage message = found.get(); + List statuses = new ArrayList<>(); + statuses.add(status(message.status())); + for(var child : SlackStore.queryAsCurrent(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", message.id()))) + { + if("FILE".equals(child.getValueString("operation"))) + { + statuses.add(status(child.getValueString("status"))); + } + } + DeliveryResult.Status outcome = statuses.contains(DeliveryResult.Status.UNAUTHORIZED) ? DeliveryResult.Status.UNAUTHORIZED + : statuses.contains(DeliveryResult.Status.FAILED) ? DeliveryResult.Status.FAILED + : statuses.stream().allMatch(s -> s == DeliveryResult.Status.DELIVERED) ? DeliveryResult.Status.DELIVERED : DeliveryResult.Status.QUEUED; + return (Optional.of(new DeliveryResult().withStatus(outcome).withExternalReference(message.id().toString()))); + } + + /******************************************************************************* + ** Public delivery status for an outbox state, including in-flight states. + *******************************************************************************/ + private static DeliveryResult.Status status(String status) + { + return (switch(status) + { + case "SENT" -> DeliveryResult.Status.DELIVERED; + case "FAILED_AUTH" -> DeliveryResult.Status.UNAUTHORIZED; + case "DEAD_LETTER", "CANCELLED", "REFUSED", "SUPPRESSED" -> DeliveryResult.Status.FAILED; + default -> DeliveryResult.Status.QUEUED; + }); + } + + /******************************************************************************* + ** Picker values arrive as either JSON numbers or strings. + *******************************************************************************/ + private static Integer workspace(Map values) + { + try + { + return (values == null || values.get("workspaceId") == null ? null : Integer.valueOf(values.get("workspaceId").toString())); + } + catch(NumberFormatException e) + { + return (null); + } + } +} + diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryAttachment.java b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryAttachment.java new file mode 100644 index 0000000..43339ce --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryAttachment.java @@ -0,0 +1,193 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + +// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + + + + +/******************************************************************************* + ** DeliveryAttachment. + *******************************************************************************/ +public class DeliveryAttachment implements java.io.Serializable +{ + private String fileName; + private String mimeType; + private Long size; + private String storageTableName; + private String storageReference; + + + + /******************************************************************************* + ** Get fileName. + *******************************************************************************/ + public String getFileName() + { + return (fileName); + } + + + + /******************************************************************************* + ** Set fileName. + *******************************************************************************/ + public void setFileName(String fileName) + { + this.fileName = fileName; + } + + + + /******************************************************************************* + ** With fileName. + *******************************************************************************/ + public DeliveryAttachment withFileName(String fileName) + { + this.fileName = fileName; + return (this); + } + + + + /******************************************************************************* + ** Get mimeType. + *******************************************************************************/ + public String getMimeType() + { + return (mimeType); + } + + + + /******************************************************************************* + ** Set mimeType. + *******************************************************************************/ + public void setMimeType(String mimeType) + { + this.mimeType = mimeType; + } + + + + /******************************************************************************* + ** With mimeType. + *******************************************************************************/ + public DeliveryAttachment withMimeType(String mimeType) + { + this.mimeType = mimeType; + return (this); + } + + + + /******************************************************************************* + ** Get size. + *******************************************************************************/ + public Long getSize() + { + return (size); + } + + + + /******************************************************************************* + ** Set size. + *******************************************************************************/ + public void setSize(Long size) + { + this.size = size; + } + + + + /******************************************************************************* + ** With size. + *******************************************************************************/ + public DeliveryAttachment withSize(Long size) + { + this.size = size; + return (this); + } + + + + /******************************************************************************* + ** Get storageTableName. + *******************************************************************************/ + public String getStorageTableName() + { + return (storageTableName); + } + + + + /******************************************************************************* + ** Set storageTableName. + *******************************************************************************/ + public void setStorageTableName(String storageTableName) + { + this.storageTableName = storageTableName; + } + + + + /******************************************************************************* + ** With storageTableName. + *******************************************************************************/ + public DeliveryAttachment withStorageTableName(String storageTableName) + { + this.storageTableName = storageTableName; + return (this); + } + + + + /******************************************************************************* + ** Get storageReference. + *******************************************************************************/ + public String getStorageReference() + { + return (storageReference); + } + + + + /******************************************************************************* + ** Set storageReference. + *******************************************************************************/ + public void setStorageReference(String storageReference) + { + this.storageReference = storageReference; + } + + + + /******************************************************************************* + ** With storageReference. + *******************************************************************************/ + public DeliveryAttachment withStorageReference(String storageReference) + { + this.storageReference = storageReference; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryDestinationContext.java b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryDestinationContext.java new file mode 100644 index 0000000..7dbb561 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryDestinationContext.java @@ -0,0 +1,65 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + +// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + + + + +/******************************************************************************* + ** DeliveryDestinationContext. + *******************************************************************************/ +public class DeliveryDestinationContext implements java.io.Serializable +{ + private QDeliveryDestinationMetaData destination; + + + + /******************************************************************************* + ** Get destination. + *******************************************************************************/ + public QDeliveryDestinationMetaData getDestination() + { + return (destination); + } + + + + /******************************************************************************* + ** Set destination. + *******************************************************************************/ + public void setDestination(QDeliveryDestinationMetaData destination) + { + this.destination = destination; + } + + + + /******************************************************************************* + ** With destination. + *******************************************************************************/ + public DeliveryDestinationContext withDestination(QDeliveryDestinationMetaData destination) + { + this.destination = destination; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryDestinationInterface.java b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryDestinationInterface.java new file mode 100644 index 0000000..a5d5563 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryDestinationInterface.java @@ -0,0 +1,69 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + +// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** Owner-scoped output delivery provider. + *******************************************************************************/ +public interface DeliveryDestinationInterface +{ + /******************************************************************************* + ** Describe the next target in the current owner session. + *******************************************************************************/ + DeliveryTargetStep describeTargets(DeliveryDestinationContext context, Map chosen) throws QException; + + + + /******************************************************************************* + ** Validate a complete target before sending. + *******************************************************************************/ + default List validate(DeliveryRequest request) throws QException + { + return (List.of()); + } + + + + /******************************************************************************* + ** Deliver idempotently using the request key. + *******************************************************************************/ + DeliveryResult deliver(DeliveryRequest request) throws QException; + + + + /******************************************************************************* + ** Reconcile a previous delivery when supported. + *******************************************************************************/ + default Optional lookup(String idempotencyKey) throws QException + { + return (Optional.empty()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryRequest.java b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryRequest.java new file mode 100644 index 0000000..2e171c8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryRequest.java @@ -0,0 +1,453 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + +// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; + + +/******************************************************************************* + ** DeliveryRequest. + *******************************************************************************/ +public class DeliveryRequest implements java.io.Serializable +{ + private String destinationName; + private String ownerUserId; + private String sourceType; + private String sourceKey; + private String title; + private String summary; + private String link; + private String idempotencyKey; + private Map targetValues = Map.of(); + private List attachments = List.of(); + private DataClassification contentClassification; + private List restrictedFieldLabels = List.of(); + private boolean leaveOutRestricted; + + + + /******************************************************************************* + ** Get destinationName. + *******************************************************************************/ + public String getDestinationName() + { + return (destinationName); + } + + + + /******************************************************************************* + ** Set destinationName. + *******************************************************************************/ + public void setDestinationName(String destinationName) + { + this.destinationName = destinationName; + } + + + + /******************************************************************************* + ** With destinationName. + *******************************************************************************/ + public DeliveryRequest withDestinationName(String destinationName) + { + this.destinationName = destinationName; + return (this); + } + + + + /******************************************************************************* + ** Get ownerUserId. + *******************************************************************************/ + public String getOwnerUserId() + { + return (ownerUserId); + } + + + + /******************************************************************************* + ** Set ownerUserId. + *******************************************************************************/ + public void setOwnerUserId(String ownerUserId) + { + this.ownerUserId = ownerUserId; + } + + + + /******************************************************************************* + ** With ownerUserId. + *******************************************************************************/ + public DeliveryRequest withOwnerUserId(String ownerUserId) + { + this.ownerUserId = ownerUserId; + return (this); + } + + + + /******************************************************************************* + ** Get sourceType. + *******************************************************************************/ + public String getSourceType() + { + return (sourceType); + } + + + + /******************************************************************************* + ** Set sourceType. + *******************************************************************************/ + public void setSourceType(String sourceType) + { + this.sourceType = sourceType; + } + + + + /******************************************************************************* + ** With sourceType. + *******************************************************************************/ + public DeliveryRequest withSourceType(String sourceType) + { + this.sourceType = sourceType; + return (this); + } + + + + /******************************************************************************* + ** Get sourceKey. + *******************************************************************************/ + public String getSourceKey() + { + return (sourceKey); + } + + + + /******************************************************************************* + ** Set sourceKey. + *******************************************************************************/ + public void setSourceKey(String sourceKey) + { + this.sourceKey = sourceKey; + } + + + + /******************************************************************************* + ** With sourceKey. + *******************************************************************************/ + public DeliveryRequest withSourceKey(String sourceKey) + { + this.sourceKey = sourceKey; + return (this); + } + + + + /******************************************************************************* + ** Get title. + *******************************************************************************/ + public String getTitle() + { + return (title); + } + + + + /******************************************************************************* + ** Set title. + *******************************************************************************/ + public void setTitle(String title) + { + this.title = title; + } + + + + /******************************************************************************* + ** With title. + *******************************************************************************/ + public DeliveryRequest withTitle(String title) + { + this.title = title; + return (this); + } + + + + /******************************************************************************* + ** Get summary. + *******************************************************************************/ + public String getSummary() + { + return (summary); + } + + + + /******************************************************************************* + ** Set summary. + *******************************************************************************/ + public void setSummary(String summary) + { + this.summary = summary; + } + + + + /******************************************************************************* + ** With summary. + *******************************************************************************/ + public DeliveryRequest withSummary(String summary) + { + this.summary = summary; + return (this); + } + + + + /******************************************************************************* + ** Get link. + *******************************************************************************/ + public String getLink() + { + return (link); + } + + + + /******************************************************************************* + ** Set link. + *******************************************************************************/ + public void setLink(String link) + { + this.link = link; + } + + + + /******************************************************************************* + ** With link. + *******************************************************************************/ + public DeliveryRequest withLink(String link) + { + this.link = link; + return (this); + } + + + + /******************************************************************************* + ** Get idempotencyKey. + *******************************************************************************/ + public String getIdempotencyKey() + { + return (idempotencyKey); + } + + + + /******************************************************************************* + ** Set idempotencyKey. + *******************************************************************************/ + public void setIdempotencyKey(String idempotencyKey) + { + this.idempotencyKey = idempotencyKey; + } + + + + /******************************************************************************* + ** With idempotencyKey. + *******************************************************************************/ + public DeliveryRequest withIdempotencyKey(String idempotencyKey) + { + this.idempotencyKey = idempotencyKey; + return (this); + } + + + + /******************************************************************************* + ** Get targetValues. + *******************************************************************************/ + public Map getTargetValues() + { + return (targetValues); + } + + + + /******************************************************************************* + ** Set targetValues. + *******************************************************************************/ + public void setTargetValues(Map targetValues) + { + this.targetValues = targetValues; + } + + + + /******************************************************************************* + ** With targetValues. + *******************************************************************************/ + public DeliveryRequest withTargetValues(Map targetValues) + { + this.targetValues = targetValues; + return (this); + } + + + + /******************************************************************************* + ** Get attachments. + *******************************************************************************/ + public List getAttachments() + { + return (attachments); + } + + + + /******************************************************************************* + ** Set attachments. + *******************************************************************************/ + public void setAttachments(List attachments) + { + this.attachments = attachments; + } + + + + /******************************************************************************* + ** With attachments. + *******************************************************************************/ + public DeliveryRequest withAttachments(List attachments) + { + this.attachments = attachments; + return (this); + } + + + + /******************************************************************************* + ** Get contentClassification. + *******************************************************************************/ + public DataClassification getContentClassification() + { + return (contentClassification); + } + + + + /******************************************************************************* + ** Set contentClassification. + *******************************************************************************/ + public void setContentClassification(DataClassification contentClassification) + { + this.contentClassification = contentClassification; + } + + + + /******************************************************************************* + ** With contentClassification. + *******************************************************************************/ + public DeliveryRequest withContentClassification(DataClassification contentClassification) + { + this.contentClassification = contentClassification; + return (this); + } + + + + /******************************************************************************* + ** Get restrictedFieldLabels. + *******************************************************************************/ + public List getRestrictedFieldLabels() + { + return (restrictedFieldLabels); + } + + + + /******************************************************************************* + ** Set restrictedFieldLabels. + *******************************************************************************/ + public void setRestrictedFieldLabels(List restrictedFieldLabels) + { + this.restrictedFieldLabels = restrictedFieldLabels; + } + + + + /******************************************************************************* + ** With restrictedFieldLabels. + *******************************************************************************/ + public DeliveryRequest withRestrictedFieldLabels(List restrictedFieldLabels) + { + this.restrictedFieldLabels = restrictedFieldLabels; + return (this); + } + + + + /******************************************************************************* + ** Get leaveOutRestricted. + *******************************************************************************/ + public boolean getLeaveOutRestricted() + { + return (leaveOutRestricted); + } + + + + /******************************************************************************* + ** Set leaveOutRestricted. + *******************************************************************************/ + public void setLeaveOutRestricted(boolean leaveOutRestricted) + { + this.leaveOutRestricted = leaveOutRestricted; + } + + + + /******************************************************************************* + ** With leaveOutRestricted. + *******************************************************************************/ + public DeliveryRequest withLeaveOutRestricted(boolean leaveOutRestricted) + { + this.leaveOutRestricted = leaveOutRestricted; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryResult.java b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryResult.java new file mode 100644 index 0000000..b378d73 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryResult.java @@ -0,0 +1,139 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + +// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + + +import java.util.List; + + +/******************************************************************************* + ** DeliveryResult. + *******************************************************************************/ +public class DeliveryResult implements java.io.Serializable +{ + /******************************************************************************* + ** Recorded delivery outcome. + *******************************************************************************/ + // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + public enum Status + { + DELIVERED, QUEUED, REFUSED, FAILED_RETRYABLE, FAILED, UNAUTHORIZED + } + + private Status status; + private String externalReference; + private List reasons = List.of(); + + + + /******************************************************************************* + ** Get status. + *******************************************************************************/ + public Status getStatus() + { + return (status); + } + + + + /******************************************************************************* + ** Set status. + *******************************************************************************/ + public void setStatus(Status status) + { + this.status = status; + } + + + + /******************************************************************************* + ** With status. + *******************************************************************************/ + public DeliveryResult withStatus(Status status) + { + this.status = status; + return (this); + } + + + + /******************************************************************************* + ** Get externalReference. + *******************************************************************************/ + public String getExternalReference() + { + return (externalReference); + } + + + + /******************************************************************************* + ** Set externalReference. + *******************************************************************************/ + public void setExternalReference(String externalReference) + { + this.externalReference = externalReference; + } + + + + /******************************************************************************* + ** With externalReference. + *******************************************************************************/ + public DeliveryResult withExternalReference(String externalReference) + { + this.externalReference = externalReference; + return (this); + } + + + + /******************************************************************************* + ** Get reasons. + *******************************************************************************/ + public List getReasons() + { + return (reasons); + } + + + + /******************************************************************************* + ** Set reasons. + *******************************************************************************/ + public void setReasons(List reasons) + { + this.reasons = reasons; + } + + + + /******************************************************************************* + ** With reasons. + *******************************************************************************/ + public DeliveryResult withReasons(List reasons) + { + this.reasons = reasons; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryTargetOption.java b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryTargetOption.java new file mode 100644 index 0000000..80aa21f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryTargetOption.java @@ -0,0 +1,129 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + +// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + + + + +/******************************************************************************* + ** DeliveryTargetOption. + *******************************************************************************/ +public class DeliveryTargetOption implements java.io.Serializable +{ + private String value; + private String label; + private String description; + + + + /******************************************************************************* + ** Get value. + *******************************************************************************/ + public String getValue() + { + return (value); + } + + + + /******************************************************************************* + ** Set value. + *******************************************************************************/ + public void setValue(String value) + { + this.value = value; + } + + + + /******************************************************************************* + ** With value. + *******************************************************************************/ + public DeliveryTargetOption withValue(String value) + { + this.value = value; + return (this); + } + + + + /******************************************************************************* + ** Get label. + *******************************************************************************/ + public String getLabel() + { + return (label); + } + + + + /******************************************************************************* + ** Set label. + *******************************************************************************/ + public void setLabel(String label) + { + this.label = label; + } + + + + /******************************************************************************* + ** With label. + *******************************************************************************/ + public DeliveryTargetOption withLabel(String label) + { + this.label = label; + return (this); + } + + + + /******************************************************************************* + ** Get description. + *******************************************************************************/ + public String getDescription() + { + return (description); + } + + + + /******************************************************************************* + ** Set description. + *******************************************************************************/ + public void setDescription(String description) + { + this.description = description; + } + + + + /******************************************************************************* + ** With description. + *******************************************************************************/ + public DeliveryTargetOption withDescription(String description) + { + this.description = description; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryTargetStep.java b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryTargetStep.java new file mode 100644 index 0000000..e0ca14b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/DeliveryTargetStep.java @@ -0,0 +1,226 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + +// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + + +import java.util.List; + + +/******************************************************************************* + ** DeliveryTargetStep. + *******************************************************************************/ +public class DeliveryTargetStep implements java.io.Serializable +{ + private String name; + private String label; + private List options = List.of(); + private boolean freeText; + private boolean complete; + private String summary; + + + + /******************************************************************************* + ** Get name. + *******************************************************************************/ + public String getName() + { + return (name); + } + + + + /******************************************************************************* + ** Set name. + *******************************************************************************/ + public void setName(String name) + { + this.name = name; + } + + + + /******************************************************************************* + ** With name. + *******************************************************************************/ + public DeliveryTargetStep withName(String name) + { + this.name = name; + return (this); + } + + + + /******************************************************************************* + ** Get label. + *******************************************************************************/ + public String getLabel() + { + return (label); + } + + + + /******************************************************************************* + ** Set label. + *******************************************************************************/ + public void setLabel(String label) + { + this.label = label; + } + + + + /******************************************************************************* + ** With label. + *******************************************************************************/ + public DeliveryTargetStep withLabel(String label) + { + this.label = label; + return (this); + } + + + + /******************************************************************************* + ** Get options. + *******************************************************************************/ + public List getOptions() + { + return (options); + } + + + + /******************************************************************************* + ** Set options. + *******************************************************************************/ + public void setOptions(List options) + { + this.options = options; + } + + + + /******************************************************************************* + ** With options. + *******************************************************************************/ + public DeliveryTargetStep withOptions(List options) + { + this.options = options; + return (this); + } + + + + /******************************************************************************* + ** Get freeText. + *******************************************************************************/ + public boolean getFreeText() + { + return (freeText); + } + + + + /******************************************************************************* + ** Set freeText. + *******************************************************************************/ + public void setFreeText(boolean freeText) + { + this.freeText = freeText; + } + + + + /******************************************************************************* + ** With freeText. + *******************************************************************************/ + public DeliveryTargetStep withFreeText(boolean freeText) + { + this.freeText = freeText; + return (this); + } + + + + /******************************************************************************* + ** Get complete. + *******************************************************************************/ + public boolean getComplete() + { + return (complete); + } + + + + /******************************************************************************* + ** Set complete. + *******************************************************************************/ + public void setComplete(boolean complete) + { + this.complete = complete; + } + + + + /******************************************************************************* + ** With complete. + *******************************************************************************/ + public DeliveryTargetStep withComplete(boolean complete) + { + this.complete = complete; + return (this); + } + + + + /******************************************************************************* + ** Get summary. + *******************************************************************************/ + public String getSummary() + { + return (summary); + } + + + + /******************************************************************************* + ** Set summary. + *******************************************************************************/ + public void setSummary(String summary) + { + this.summary = summary; + } + + + + /******************************************************************************* + ** With summary. + *******************************************************************************/ + public DeliveryTargetStep withSummary(String summary) + { + this.summary = summary; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/delivery/standin/QDeliveryDestinationMetaData.java b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/QDeliveryDestinationMetaData.java new file mode 100644 index 0000000..1efc14e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/delivery/standin/QDeliveryDestinationMetaData.java @@ -0,0 +1,396 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + +// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + + +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; + + +/******************************************************************************* + ** QDeliveryDestinationMetaData. + *******************************************************************************/ +public class QDeliveryDestinationMetaData implements java.io.Serializable +{ + /******************************************************************************* + ** Policy for content above the destination ceiling. + *******************************************************************************/ + // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) + public enum AboveMaxClassification + { + REFUSE, LINK_ONLY + } + + private String name; + private String label; + private String icon; + private String permissionName; + private String emailProviderName; + private Integer order = 0; + private QCodeReference codeReference; + private DataClassification maxClassification = DataClassification.PUBLIC; + private Long maxAttachmentBytes; + private boolean allowBearerLinks; + private AboveMaxClassification aboveMaxClassification = AboveMaxClassification.REFUSE; + + + + /******************************************************************************* + ** Get name. + *******************************************************************************/ + public String getName() + { + return (name); + } + + + + /******************************************************************************* + ** Set name. + *******************************************************************************/ + public void setName(String name) + { + this.name = name; + } + + + + /******************************************************************************* + ** With name. + *******************************************************************************/ + public QDeliveryDestinationMetaData withName(String name) + { + this.name = name; + return (this); + } + + + + /******************************************************************************* + ** Get label. + *******************************************************************************/ + public String getLabel() + { + return (label); + } + + + + /******************************************************************************* + ** Set label. + *******************************************************************************/ + public void setLabel(String label) + { + this.label = label; + } + + + + /******************************************************************************* + ** With label. + *******************************************************************************/ + public QDeliveryDestinationMetaData withLabel(String label) + { + this.label = label; + return (this); + } + + + + /******************************************************************************* + ** Get icon. + *******************************************************************************/ + public String getIcon() + { + return (icon); + } + + + + /******************************************************************************* + ** Set icon. + *******************************************************************************/ + public void setIcon(String icon) + { + this.icon = icon; + } + + + + /******************************************************************************* + ** With icon. + *******************************************************************************/ + public QDeliveryDestinationMetaData withIcon(String icon) + { + this.icon = icon; + return (this); + } + + + + /******************************************************************************* + ** Get permissionName. + *******************************************************************************/ + public String getPermissionName() + { + return (permissionName); + } + + + + /******************************************************************************* + ** Set permissionName. + *******************************************************************************/ + public void setPermissionName(String permissionName) + { + this.permissionName = permissionName; + } + + + + /******************************************************************************* + ** With permissionName. + *******************************************************************************/ + public QDeliveryDestinationMetaData withPermissionName(String permissionName) + { + this.permissionName = permissionName; + return (this); + } + + + + /******************************************************************************* + ** Get emailProviderName. + *******************************************************************************/ + public String getEmailProviderName() + { + return (emailProviderName); + } + + + + /******************************************************************************* + ** Set emailProviderName. + *******************************************************************************/ + public void setEmailProviderName(String emailProviderName) + { + this.emailProviderName = emailProviderName; + } + + + + /******************************************************************************* + ** With emailProviderName. + *******************************************************************************/ + public QDeliveryDestinationMetaData withEmailProviderName(String emailProviderName) + { + this.emailProviderName = emailProviderName; + return (this); + } + + + + /******************************************************************************* + ** Get order. + *******************************************************************************/ + public Integer getOrder() + { + return (order); + } + + + + /******************************************************************************* + ** Set order. + *******************************************************************************/ + public void setOrder(Integer order) + { + this.order = order; + } + + + + /******************************************************************************* + ** With order. + *******************************************************************************/ + public QDeliveryDestinationMetaData withOrder(Integer order) + { + this.order = order; + return (this); + } + + + + /******************************************************************************* + ** Get codeReference. + *******************************************************************************/ + public QCodeReference getCodeReference() + { + return (codeReference); + } + + + + /******************************************************************************* + ** Set codeReference. + *******************************************************************************/ + public void setCodeReference(QCodeReference codeReference) + { + this.codeReference = codeReference; + } + + + + /******************************************************************************* + ** With codeReference. + *******************************************************************************/ + public QDeliveryDestinationMetaData withCodeReference(QCodeReference codeReference) + { + this.codeReference = codeReference; + return (this); + } + + + + /******************************************************************************* + ** Get maxClassification. + *******************************************************************************/ + public DataClassification getMaxClassification() + { + return (maxClassification); + } + + + + /******************************************************************************* + ** Set maxClassification. + *******************************************************************************/ + public void setMaxClassification(DataClassification maxClassification) + { + this.maxClassification = maxClassification; + } + + + + /******************************************************************************* + ** With maxClassification. + *******************************************************************************/ + public QDeliveryDestinationMetaData withMaxClassification(DataClassification maxClassification) + { + this.maxClassification = maxClassification; + return (this); + } + + + + /******************************************************************************* + ** Get maxAttachmentBytes. + *******************************************************************************/ + public Long getMaxAttachmentBytes() + { + return (maxAttachmentBytes); + } + + + + /******************************************************************************* + ** Set maxAttachmentBytes. + *******************************************************************************/ + public void setMaxAttachmentBytes(Long maxAttachmentBytes) + { + this.maxAttachmentBytes = maxAttachmentBytes; + } + + + + /******************************************************************************* + ** With maxAttachmentBytes. + *******************************************************************************/ + public QDeliveryDestinationMetaData withMaxAttachmentBytes(Long maxAttachmentBytes) + { + this.maxAttachmentBytes = maxAttachmentBytes; + return (this); + } + + + + /******************************************************************************* + ** Get allowBearerLinks. + *******************************************************************************/ + public boolean getAllowBearerLinks() + { + return (allowBearerLinks); + } + + + + /******************************************************************************* + ** Set allowBearerLinks. + *******************************************************************************/ + public void setAllowBearerLinks(boolean allowBearerLinks) + { + this.allowBearerLinks = allowBearerLinks; + } + + + + /******************************************************************************* + ** With allowBearerLinks. + *******************************************************************************/ + public QDeliveryDestinationMetaData withAllowBearerLinks(boolean allowBearerLinks) + { + this.allowBearerLinks = allowBearerLinks; + return (this); + } + + + + /******************************************************************************* + ** Get aboveMaxClassification. + *******************************************************************************/ + public AboveMaxClassification getAboveMaxClassification() + { + return (aboveMaxClassification); + } + + + + /******************************************************************************* + ** Set aboveMaxClassification. + *******************************************************************************/ + public void setAboveMaxClassification(AboveMaxClassification aboveMaxClassification) + { + this.aboveMaxClassification = aboveMaxClassification; + } + + + + /******************************************************************************* + ** With aboveMaxClassification. + *******************************************************************************/ + public QDeliveryDestinationMetaData withAboveMaxClassification(AboveMaxClassification aboveMaxClassification) + { + this.aboveMaxClassification = aboveMaxClassification; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplates.java b/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplates.java index abe99e6..c6195cf 100644 --- a/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplates.java +++ b/src/main/java/com/kingsrook/qbits/slack/template/SlackTemplates.java @@ -109,7 +109,8 @@ public final class SlackTemplates { String schema = "[{\"name\":\"title\",\"type\":\"STRING\"},{\"name\":\"body\",\"type\":\"STRING\"}," + "{\"name\":\"link\",\"type\":\"URL\",\"required\":true},{\"name\":\"severity\",\"type\":\"STRING\"}," - + "{\"name\":\"context\",\"type\":\"STRING\"},{\"name\":\"items\",\"type\":\"LIST\"}]"; + + "{\"name\":\"context\",\"type\":\"STRING\"},{\"name\":\"items\",\"type\":\"LIST\"}," + + "{\"name\":\"summary\",\"type\":\"STRING\"},{\"name\":\"source\",\"type\":\"STRING\"},{\"name\":\"runTime\",\"type\":\"STRING\"}]"; String blocks = "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"{{severity|:information_source:}} *{{title}}*\\n{{body}}\"}}," + "{\"type\":\"actions\",\"elements\":[{\"type\":\"button\",\"text\":{\"type\":\"plain_text\",\"text\":\"View\"},\"url\":\"{{link}}\"}]}," + "{\"type\":\"context\",\"elements\":[{\"type\":\"mrkdwn\",\"text\":\"{{context|QQQ notification}}\"}]}]"; @@ -117,6 +118,10 @@ public final class SlackTemplates { String text = "digest".equals(name) ? "*{{title}}*\n{{#each items}}{{@index}}. {{this.title}}\n{{/each}}{{link}}" : "*{{title}}*\n{{body}}\n{{link}}"; + if("delivery".equals(name)) + { + text = "*{{title}}*\n{{body}}\n{{link}}\n{{source}} {{runTime}}"; + } com.kingsrook.qbits.slack.api.SlackApi.ensureTemplate(new com.kingsrook.qbits.slack.api.SlackApiTemplate(name, name, text, "notification".equals(name) ? blocks : null, schema, "TRANSACTIONAL")); } diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackDeliveryDestinationTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackDeliveryDestinationTest.java new file mode 100644 index 0000000..75df0fc --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackDeliveryDestinationTest.java @@ -0,0 +1,327 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.slack; + +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.slack.api.SlackApi; +import com.kingsrook.qbits.slack.delivery.SlackDeliveryDestination; +import com.kingsrook.qbits.slack.delivery.SlackDispatcher; +import com.kingsrook.qbits.slack.delivery.standin.DeliveryAttachment; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.DeliveryDestinationContext; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.DeliveryRequest; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.delivery.standin.DeliveryResult; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qbits.slack.template.SlackTemplates; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; +import com.kingsrook.qqq.backend.core.actions.tables.StorageAction; +import com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Delivery SPI acceptance against H2, core storage and the fake Slack server. + *******************************************************************************/ +class SlackDeliveryDestinationTest +{ + private SlackTestEnv env; + private int workspace; + private int connection; + private final SlackDeliveryDestination destination = new SlackDeliveryDestination(); + + /******************************************************************************* + ** Two tenants, with one connected owner and a member-only channel. + *******************************************************************************/ + @BeforeEach + void setup() throws Exception + { + env = SlackTestEnv.create(true, c -> c.withPhiPatterns(List.of("PHI-CANARY"))); + env.tenant("owner", 1); + env.tenant("other", 2); + workspace = env.workspace("T1", 1, "xoxb-test", "BOT"); + connection = env.connection("owner", workspace, "U1", "xoxp-test"); + env.slack().members("C1", "U1", "BOT"); + env.slack().name("C1", "reports"); + SlackTemplates.seed(); + env.asOwner("owner"); + } + + /******************************************************************************* + ** Restore process globals. + *******************************************************************************/ + @AfterEach + void close() + { + env.close(); + } + + /******************************************************************************* + ** A complete direct-message request. + *******************************************************************************/ + private DeliveryRequest request(String key) + { + return (new DeliveryRequest().withOwnerUserId("owner").withDestinationName("slack") + .withTargetValues(Map.of("workspaceId", workspace, "where", "me")).withIdempotencyKey(key) + .withTitle("Weekly report").withSummary("All jobs complete").withLink("https://example.com/reports/1") + .withSourceType("SCHEDULED_REPORT").withSourceKey("report:1").withContentClassification(DataClassification.CONFIDENTIAL)); + } + + /******************************************************************************* + ** Only this user's tenant-visible active workspaces and member channels. + *******************************************************************************/ + @Test + void targets() throws Exception + { + int second = env.workspace("T2", 1, "xoxb-two", "BOT"); + env.connection("owner", second, "U2", "xoxp-two"); + int foreign = env.workspace("FOREIGN", 2, "xoxb-other", "BOT"); + env.connection("other", foreign, "U3", "xoxp-other"); + env.slack().name("C2", "not-a-member"); + env.slack().members("C2", "U3", "BOT"); + env.asOwner("owner"); + var context = new DeliveryDestinationContext().withDestination(destination.metadata()); + var first = destination.describeTargets(context, Map.of()); + assertThat(first.getLabel()).isEqualTo("Workspace"); + assertThat(first.getOptions()).extracting(o -> o.getValue()).containsExactlyInAnyOrder("" + workspace, "" + second); + var next = destination.describeTargets(context, Map.of("workspaceId", "" + workspace)); + assertThat(next.getLabel()).isEqualTo("Where"); + assertThat(next.getComplete()).isFalse(); + assertThat(next.getOptions()).extracting(o -> o.getValue()).containsExactly("me", "channel:" + workspace + "/C1"); + var complete = destination.describeTargets(context, Map.of("workspaceId", workspace, "where", "channel:" + workspace + "/C1")); + assertThat(complete.getComplete()).isTrue(); + assertThat(complete.getSummary()).isEqualTo("Slack: #reports in Team T1"); + assertThat(destination.describeTargets(context, Map.of("workspaceId", workspace, "where", "me")).getSummary()).isEqualTo("Slack: DM to me in Team T1"); + assertThat(context.getDestination().getName()).isEqualTo("slack"); + assertThat(context.getDestination().getMaxClassification()).isEqualTo(DataClassification.CONFIDENTIAL); + assertThat(context.getDestination().getAboveMaxClassification().name()).isEqualTo("REFUSE"); + assertThat(context.getDestination().getAllowBearerLinks()).isFalse(); + assertThat(context.getDestination().getMaxAttachmentBytes()).isEqualTo(env.config().getMaxAttachmentBytes()); + } + + /******************************************************************************* + ** DM and channel use the delivery template and preserve the idempotency key. + *******************************************************************************/ + @Test + void deliverAndDedupe() throws Exception + { + for(String where : List.of("me", "channel:" + workspace + "/C1")) + { + env.asOwner("owner"); + DeliveryRequest request = request(where).withTargetValues(Map.of("workspaceId", workspace, "where", where)); + DeliveryResult queued = destination.deliver(request); + assertThat(queued.getStatus()).isEqualTo(DeliveryResult.Status.QUEUED); + assertThat(destination.deliver(request).getExternalReference()).isEqualTo(queued.getExternalReference()); + env.asSystem(); + assertThat(new SlackDispatcher().sendNow(Integer.valueOf(queued.getExternalReference()))).isEqualTo(SlackDispatcher.SendResult.SENT); + env.asOwner("owner"); + assertThat(destination.deliver(request).getStatus()).isEqualTo(DeliveryResult.Status.DELIVERED); + } + assertThat(env.slack().requests("chat.postMessage")).hasSize(2); + assertThat(env.slack().requests("chat.postMessage").getFirst().json().getString("text")) + .contains("Weekly report", "All jobs complete", "https://example.com/reports/1", "SCHEDULED_REPORT", "2026-10-14T12:00:00Z"); + } + + /******************************************************************************* + ** Every outbox state has a public delivery mapping, visible only to its owner. + *******************************************************************************/ + @Test + void lookupStatuses() throws Exception + { + int id = Integer.parseInt(destination.deliver(request("states")).getExternalReference()); + Map states = Map.of( + "SENT", DeliveryResult.Status.DELIVERED, "DEAD_LETTER", DeliveryResult.Status.FAILED, + "FAILED_AUTH", DeliveryResult.Status.UNAUTHORIZED, "QUEUED", DeliveryResult.Status.QUEUED, + "RETRY_WAIT", DeliveryResult.Status.QUEUED, "SENDING", DeliveryResult.Status.QUEUED); + for(var state : states.entrySet()) + { + env.set(SlackTables.MESSAGE, id, "status", state.getKey()); + assertThat(destination.lookup("states").orElseThrow().getStatus()).isEqualTo(state.getValue()); + } + assertThat(destination.lookup("missing")).isEmpty(); + env.asOwner("other"); + assertThat(destination.lookup("states")).isEmpty(); + assertThat(destination.deliver(request("impersonate")).getStatus()).isEqualTo(DeliveryResult.Status.UNAUTHORIZED); + } + + /******************************************************************************* + ** Revoked connections are rejected without any Slack request. + *******************************************************************************/ + @Test + void revokedAndRestricted() throws Exception + { + env.set(SlackTables.CONNECTION, connection, "status", "REVOKED"); + int before = env.slack().requests().size(); + assertThat(destination.deliver(request("revoked")).getStatus()).isEqualTo(DeliveryResult.Status.UNAUTHORIZED); + assertThat(env.slack().requests()).hasSize(before); + env.set(SlackTables.CONNECTION, connection, "status", "ACTIVE"); + assertThat(destination.deliver(request("canary").withSummary("PHI-CANARY")).getStatus()).isEqualTo(DeliveryResult.Status.REFUSED); + assertThat(destination.deliver(request("classified").withContentClassification(DataClassification.PHI)).getStatus()).isEqualTo(DeliveryResult.Status.REFUSED); + assertThat(SlackStore.query(SlackTables.MESSAGE, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty(); + assertThat(env.slack().requests("chat.postMessage")).isEmpty(); + } + + /******************************************************************************* + ** Core memory storage provides a byte stream without a filesystem fixture. + *******************************************************************************/ + private DeliveryAttachment attachment(byte[] bytes) throws Exception + { + env.asSystem(); + env.instance().addBackend(new QBackendMetaData().withName("storage").withBackendType(MemoryBackendModule.class)); + env.instance().addTable(new QTableMetaData().withName("output_file").withBackendName("storage").withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("data", QFieldType.BLOB))); + String reference = java.util.UUID.randomUUID().toString(); + try(var output = new StorageAction().createOutputStream(new StorageInput("output_file").withReference(reference))) + { + output.write(bytes); + } + env.asOwner("owner"); + return (new DeliveryAttachment().withFileName("report.bin").withMimeType("application/octet-stream") + .withSize((long) bytes.length).withStorageTableName("output_file").withStorageReference(reference)); + } + + /******************************************************************************* + ** Three upload calls carry exact binary bytes, and expired leases reconcile + ** through the title marker without a second upload. + *******************************************************************************/ + @Test + void uploadAndCrashReconcile() throws Exception + { + byte[] bytes = new byte[] {0, 1, 2, -1, -128, 10, 42}; + DeliveryAttachment attachment = attachment(bytes); + DeliveryResult result = destination.deliver(request("file").withAttachments(List.of(attachment))); + int parent = Integer.parseInt(result.getExternalReference()); + var children = SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", parent)); + assertThat(children).hasSize(1); + int file = children.getFirst().getValueInteger("id"); + assertThat(children.getFirst().getValueString("status")).isEqualTo("WAITING_PARENT"); + env.asSystem(); + assertThat(new SlackDispatcher().sendNow(parent)).isEqualTo(SlackDispatcher.SendResult.SENT); + env.asOwner("owner"); + assertThat(destination.lookup("file").orElseThrow().getStatus()).isEqualTo(DeliveryResult.Status.QUEUED); + env.asSystem(); + assertThat(new SlackDispatcher().sendNow(file)).isEqualTo(SlackDispatcher.SendResult.SENT); + QRecord stored = env.row(SlackTables.MESSAGE, file); + assertThat(env.slack().uploaded(stored.getValueString("slackFileId"))).isEqualTo(bytes); + assertThat(env.slack().requests("files.getUploadURLExternal")).hasSize(1); + assertThat(env.slack().requests("files.completeUploadExternal")).hasSize(1); + var complete = env.slack().requests("files.completeUploadExternal").getFirst().json(); + assertThat(complete.getString("channel_id")).isEqualTo("DU1"); + assertThat(complete.getString("thread_ts")).isNotBlank(); + assertThat(complete.getJSONArray("files").getJSONObject(0).getString("title")).endsWith(" qqq:" + file); + assertThat(env.slack().requests("upload/" + stored.getValueString("slackFileId")).getFirst().authorization()).isNull(); + env.set(SlackTables.MESSAGE, file, "status", "SENDING"); + env.set(SlackTables.MESSAGE, file, "slackFileId", null); + env.set(SlackTables.MESSAGE, file, "leaseExpiresDate", env.clock().instant().minusSeconds(1)); + assertThat(new SlackDispatcher().sendNow(file)).isEqualTo(SlackDispatcher.SendResult.SENT); + assertThat(env.slack().requests("files.list")).hasSize(1); + assertThat(env.slack().requests("files.list").getFirst().query()).contains("channel=DU1", "ts_from="); + assertThat(env.slack().requests("files.getUploadURLExternal")).hasSize(1); + env.asOwner("owner"); + assertThat(destination.deliver(request("file").withAttachments(List.of(attachment))).getStatus()).isEqualTo(DeliveryResult.Status.DELIVERED); + assertThat(SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", parent))).hasSize(1); + } + + /******************************************************************************* + ** An unconfirmed file never re-uploads on a later RETRY_WAIT attempt. + *******************************************************************************/ + @Test + void unknownFileWaits() throws Exception + { + var attachment = attachment(new byte[] {1, 2, 3}); + int parent = Integer.parseInt(destination.deliver(request("unknown").withAttachments(List.of(attachment))).getExternalReference()); + int file = SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", parent)).getFirst().getValueInteger("id"); + env.asSystem(); + new SlackDispatcher().sendNow(parent); + env.set(SlackTables.MESSAGE, file, "status", "SENDING"); + env.set(SlackTables.MESSAGE, file, "leaseExpiresDate", env.clock().instant().minusSeconds(1)); + assertThat(new SlackDispatcher().sendNow(file)).isEqualTo(SlackDispatcher.SendResult.RETRY_WAIT); + assertThat(new SlackDispatcher().sendNow(file)).isEqualTo(SlackDispatcher.SendResult.RETRY_WAIT); + assertThat(env.slack().requests("files.getUploadURLExternal")).isEmpty(); + assertThat(env.slack().requests("files.list")).hasSize(2); + } + + /******************************************************************************* + ** A queued file cannot use a revoked connection, even after its parent posted. + ** An invalid or oversized attachment cannot leave a partial text delivery. + *******************************************************************************/ + @Test + void fileAuthorizationAndSize() throws Exception + { + var attachment = attachment(new byte[] {1, 2, 3}); + assertThat(destination.deliver(request("oversize").withAttachments(List.of( + attachment.withSize(env.config().getMaxAttachmentBytes() + 1)))).getStatus()).isEqualTo(DeliveryResult.Status.REFUSED); + assertThat(destination.lookup("oversize")).isEmpty(); + int parent = Integer.parseInt(destination.deliver(request("revoke-file").withAttachments(List.of(attachment.withSize(3L)))).getExternalReference()); + int file = SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", parent)).getFirst().getValueInteger("id"); + env.asSystem(); + new SlackDispatcher().sendNow(parent); + env.set(SlackTables.CONNECTION, connection, "status", "REVOKED"); + int before = env.slack().requests().size(); + assertThat(new SlackDispatcher().sendNow(file)).isEqualTo(SlackDispatcher.SendResult.FAILED_AUTH); + assertThat(env.slack().requests()).hasSize(before); + env.asOwner("owner"); + assertThat(destination.lookup("revoke-file").orElseThrow().getStatus()).isEqualTo(DeliveryResult.Status.UNAUTHORIZED); + } + + /******************************************************************************* + ** A failed completion is reconciled on later attempts, never re-uploaded. + *******************************************************************************/ + @Test + void completionFailureReconciles() throws Exception + { + var attachment = attachment(new byte[] {4, 5, 6}); + int parent = Integer.parseInt(destination.deliver(request("completion").withAttachments(List.of(attachment))).getExternalReference()); + int file = SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", parent)).getFirst().getValueInteger("id"); + env.asSystem(); + new SlackDispatcher().sendNow(parent); + env.slack().script("files.completeUploadExternal", FakeSlackServer.Reply.of(503, "{}")); + assertThat(new SlackDispatcher().sendNow(file)).isEqualTo(SlackDispatcher.SendResult.RETRY_WAIT); + assertThat(new SlackDispatcher().sendNow(file)).isEqualTo(SlackDispatcher.SendResult.RETRY_WAIT); + assertThat(env.slack().requests("files.getUploadURLExternal")).hasSize(1); + assertThat(env.slack().requests("files.list")).hasSize(1); + } + + /******************************************************************************* + ** Public session helper replaces keys and derives body access from permission. + *******************************************************************************/ + @Test + void sessionKeys() throws Exception + { + var session = env.asOwner("owner"); + session.withPermissions("slack.message.body"); + SlackApi.applySessionKeys(session, true); + assertThat(session.getSecurityKeyValues()).containsKeys(SlackSecurity.KEY_USER, SlackSecurity.KEY_USER_ALL_ACCESS, SlackSecurity.KEY_BODY_ACCESS); + SlackApi.applySessionKeys(session, false); + assertThat(session.getSecurityKeyValues()).containsKey(SlackSecurity.KEY_USER).doesNotContainKey(SlackSecurity.KEY_USER_ALL_ACCESS); + SlackApi.applySessionKeys(null, false); + } +} + -- 2.54.0