feat: phase 1e - Slack delivery destination, file uploads, SlackApi.applySessionKeys #7

Merged
KofTwentyTwo merged 3 commits from feature/delivery-destination into develop 2026-10-14 18:33:24 +00:00
25 changed files with 2576 additions and 16 deletions

No files matched your search

+2 -2
View File
@@ -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://<host>/qqq/hooks/slack/oauth/callback`.
- **Interactivity URL (phase 2):** `https://<host>/qqq/hooks/slack/interactions`.
+2
View File
@@ -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),
+2
View File
@@ -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:
@@ -147,6 +147,7 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
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);
@@ -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.
*******************************************************************************/
@@ -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<String, Serializable> 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<String> 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<String> 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<String, Serializable> 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<String> 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<DeliveryResult> lookup(String key) throws QException
{
Optional<SlackApiMessage> found = SlackApi.getMessageByIdempotencyKey(key);
if(found.isEmpty())
{
return (Optional.empty());
}
SlackApiMessage message = found.get();
List<DeliveryResult.Status> 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<String, Serializable> values)
{
try
{
return (values == null || values.get("workspaceId") == null ? null : Integer.valueOf(values.get("workspaceId").toString()));
}
catch(NumberFormatException e)
{
return (null);
}
}
}
@@ -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));
}
/*******************************************************************************
@@ -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);
}
}
@@ -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);
}
}
@@ -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<String, Serializable> chosen) throws QException;
/*******************************************************************************
** Validate a complete target before sending.
*******************************************************************************/
default List<String> 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<DeliveryResult> lookup(String idempotencyKey) throws QException
{
return (Optional.empty());
}
}
@@ -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<String, Serializable> targetValues = Map.of();
private List<DeliveryAttachment> attachments = List.of();
private DataClassification contentClassification;
private List<String> 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<String, Serializable> getTargetValues()
{
return (targetValues);
}
/*******************************************************************************
** Set targetValues.
*******************************************************************************/
public void setTargetValues(Map<String, Serializable> targetValues)
{
this.targetValues = targetValues;
}
/*******************************************************************************
** With targetValues.
*******************************************************************************/
public DeliveryRequest withTargetValues(Map<String, Serializable> targetValues)
{
this.targetValues = targetValues;
return (this);
}
/*******************************************************************************
** Get attachments.
*******************************************************************************/
public List<DeliveryAttachment> getAttachments()
{
return (attachments);
}
/*******************************************************************************
** Set attachments.
*******************************************************************************/
public void setAttachments(List<DeliveryAttachment> attachments)
{
this.attachments = attachments;
}
/*******************************************************************************
** With attachments.
*******************************************************************************/
public DeliveryRequest withAttachments(List<DeliveryAttachment> 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<String> getRestrictedFieldLabels()
{
return (restrictedFieldLabels);
}
/*******************************************************************************
** Set restrictedFieldLabels.
*******************************************************************************/
public void setRestrictedFieldLabels(List<String> restrictedFieldLabels)
{
this.restrictedFieldLabels = restrictedFieldLabels;
}
/*******************************************************************************
** With restrictedFieldLabels.
*******************************************************************************/
public DeliveryRequest withRestrictedFieldLabels(List<String> 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);
}
}
@@ -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<String> 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<String> getReasons()
{
return (reasons);
}
/*******************************************************************************
** Set reasons.
*******************************************************************************/
public void setReasons(List<String> reasons)
{
this.reasons = reasons;
}
/*******************************************************************************
** With reasons.
*******************************************************************************/
public DeliveryResult withReasons(List<String> reasons)
{
this.reasons = reasons;
return (this);
}
}
@@ -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);
}
}
@@ -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<DeliveryTargetOption> 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<DeliveryTargetOption> getOptions()
{
return (options);
}
/*******************************************************************************
** Set options.
*******************************************************************************/
public void setOptions(List<DeliveryTargetOption> options)
{
this.options = options;
}
/*******************************************************************************
** With options.
*******************************************************************************/
public DeliveryTargetStep withOptions(List<DeliveryTargetOption> 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);
}
}
@@ -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);
}
}
@@ -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);
@@ -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.
@@ -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<Attachment> attachments = java.util.List.of();
/*******************************************************************************
** Attachments are persisted atomically with the parent post.
*******************************************************************************/
public java.util.List<Attachment> getAttachments()
{
return (attachments);
}
/*******************************************************************************
** Supply immutable attachment descriptors.
*******************************************************************************/
public SlackSendRequest withAttachments(java.util.List<Attachment> value)
{
attachments = java.util.List.copyOf(value);
return (this);
}
private String templateName = null;
private java.util.Map<String, java.io.Serializable> variables = java.util.Map.of();
private String contentClassification = null;
@@ -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<SlackSendRequest.Attachment> 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"));
}
}
@@ -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"));
}
@@ -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;
@@ -51,6 +51,16 @@ import org.json.JSONObject;
public class FakeSlackServer implements AutoCloseable
{
private final HttpServer server;
private final Map<String, byte[]> uploads = new ConcurrentHashMap<>();
private final Map<String, JSONArray> files = new ConcurrentHashMap<>();
/*******************************************************************************
** Exact raw upload bytes, including non-UTF8 content.
*******************************************************************************/
public byte[] uploaded(String fileId)
{
return (uploads.get(fileId));
}
private final List<Recorded> requests = new CopyOnWriteArrayList<>();
private final Map<String, Deque<Reply>> 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":
@@ -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");
}
}
@@ -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<String, DeliveryResult.Status> 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
View File
@@ -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),