feat: phase 1e - Slack delivery destination, file uploads, SlackApi.applySessionKeys #7
No files matched your search
+2
-2
@@ -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`.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
+396
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user