fix: start on QQQ 4.1.0-RC.7 (real user-preferences SPI; #1194 fields) #10

Merged
KofTwentyTwo merged 2 commits from fix/rc7-preferences into develop 2026-10-15 00:51:17 +00:00
12 changed files with 584 additions and 132 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ Design: [docs/DESIGN.md](docs/DESIGN.md).
## Requirements
- Java 21
- QQQ 4.1 (currently built against 4.1.0-RC.6)
- QQQ 4.1 (currently built against 4.1.0-RC.7)
- A Slack app created from [docs/slack-app-manifest.yaml](docs/slack-app-manifest.yaml)
## Install
+15 -8
View File
@@ -230,14 +230,15 @@ have changed since the message was queued. Then it decrypts **only** the token o
There is no addressing by email, and no raw Slack ids without a workspace. `MultiParty` gives one outbox row
per recipient. An unknown or unauthorized target is REFUSED with a reason, never an exception.
- **Capabilities** (core #1194): `TEMPLATES`, `IDEMPOTENCY`, `CATEGORIES`, `LINKED_RECORDS`, `ASYNC_DELIVERY`
and `MESSAGE_LOOKUP`.
- **Output:** `QUEUED` with the id, `DUPLICATE` with the earlier id, or `REFUSED` or `SUPPRESSED` with reasons.
- **Capabilities** (core #1194): `TEMPLATES`, `IDEMPOTENCY`, `CATEGORIES`, `LINKED_RECORDS`, `ASYNC_DELIVERY`.
- **Output:** `QUEUED` with the id, `DUPLICATE` with the earlier id, or `REFUSED` with reasons.
For multiple recipients, `messageId` contains the accepted IDs (a list when more than one), and
idempotency keys are scoped by recipient. A partial refusal returns `REFUSED` with accepted IDs;
otherwise the status is `QUEUED` if any row is new, or `DUPLICATE` if all already existed.
- **Content:** `subject` becomes the bold first line and the notification text. Content items are mrkdwn,
escaped. A Block Kit JSON body needs `slack.send.blocks`. A template wins.
- **Dependency:** QQQ 4.1.0-RC.6 has the plain provider SPI. #1194's rich fields arrive in RC.7.
- Until then the bridge maps the RC.6 fields, and `SlackApi` carries the rest.
- The gap is marked `STAND-IN(core=#1194, removeBy=RC.8)`.
- **Dependency:** QQQ 4.1.0-RC.7. The bridge maps template name, variables, idempotency key,
category, linked record and sender name directly to the outbox. Locale remains advisory and is ignored.
### 4.3 Public API `com.kingsrook.qbits.slack.api` (mirrors `EmailApi`)
@@ -529,7 +530,13 @@ qbit-slack's own Preferences section (core #1162) has:
- the list of Connections, read-only;
- the actions **Connect Slack**, **Disconnect** and **Send me a test message**;
- for tenant admins, **Install for my organization**.
- with `slack.workspace.install`, **Install for my organization**.
The section requires `slack.connect`, appears in the user menu, and rejects PATCH requests.
Actions are filtered per session; test messages also require `slack.send`. RC.7 has no action-level
permission property, so the section returns a filtered metadata view without mutating its shared
registrations. Connect and Install return their authorize URL in `UserPreferenceActionResult.message`;
RC.7 has no external-link or redirect result field, so this does not automatically navigate the client.
Without qbit-alerts, callers use `SendMessageAction` (`slack`, `me` or `user:<id>`) directly.
@@ -694,7 +701,7 @@ DDL is `docs/ddl/slack-postgres.sql`. kof22 applies it as a Liquibase changeset.
**Phase 1f: app, home and preferences section.**
- Built on core #1162 (RC.7). Until then the work is behind a stand-in.
- Built directly on the core #1162 preferences SPI (RC.7), with a class-availability guard for registration.
- Screenshots: home, a message and a connection, light and dark, at 390 and 1440 px.
**Phase 1g: qbit-alerts routing (section 10).** A separate qbit-alerts PR.
+1 -1
View File
@@ -213,7 +213,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -150,7 +150,10 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
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);
if(isPreferencesAvailable())
{
com.kingsrook.qbits.slack.preferences.SlackPreferencesProvider.register(qInstance);
}
com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance);
com.kingsrook.qbits.slack.interactivity.SlackActions.registerBuiltIns(qInstance, getQBitConfig());
try
@@ -168,6 +171,24 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
/*******************************************************************************
** Guard only class availability; registration errors must still surface.
*******************************************************************************/
private static boolean isPreferencesAvailable()
{
try
{
Class.forName("com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData", false, SlackQBitProducer.class.getClassLoader());
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
** The config.
*******************************************************************************/
@@ -21,21 +21,23 @@
package com.kingsrook.qbits.slack.bridge;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qbits.slack.outbox.SlackSendRequest;
import com.kingsrook.qbits.slack.outbox.SlackSendResult;
import com.kingsrook.qbits.slack.outbox.SlackService;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageStatus;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
@@ -43,22 +45,15 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** producer under the config's bridgeProviderName), so any process sends with
** core SendMessageAction and depends only on core (docs/DESIGN.md 4.2).
**
** How the QQQ 4.1.0-RC.6 input maps:
** - to: a Party's address is a Slack address (me, user:&lt;id&gt;,
** channel:&lt;workspace&gt;/&lt;channel&gt;, channel:&lt;name&gt;, #&lt;name&gt;,
** group:&lt;name&gt;); a MultiParty gives one outbox row per recipient;
** - subject: the bold first line of the message;
** - contentList: the bodies, one per line, as plain text (the service escapes it);
** - the owner is the current session's user, so an unconnected user, another
** user's channel or another tenant's workspace is refused.
**
** A refusal is a QUserFacingException with the reason. RC.6 has no REFUSED or
** QUEUED status on its output and no idempotency field, so the status is only
** logged here.
** RC.7 rich input maps directly to the outbox request. The owner remains the
** session user. For multiple recipients, messageId is a list of accepted IDs,
** and idempotency keys are scoped by recipient. Any refusal yields REFUSED
** with reasons; accepted recipients remain queued. Otherwise the aggregate is
** QUEUED if any row is new, or DUPLICATE if all already existed.
*******************************************************************************/
public class SlackOutboxMessagingProvider implements MessagingProviderInterface
{
private static final QLogger LOG = QLogger.getLogger(SlackOutboxMessagingProvider.class);
@@ -74,49 +69,75 @@ public class SlackOutboxMessagingProvider implements MessagingProviderInterface
/*******************************************************************************
** Queue one outbox row per recipient. Anything refused is reported after the
** others were queued, as one QUserFacingException.
** Declare only capabilities implemented by the outbox bridge.
*******************************************************************************/
@Override
public Set<MessagingProviderCapability> getCapabilities()
{
return (Set.of(MessagingProviderCapability.TEMPLATES, MessagingProviderCapability.IDEMPOTENCY,
MessagingProviderCapability.CATEGORIES, MessagingProviderCapability.LINKED_RECORDS, MessagingProviderCapability.ASYNC_DELIVERY));
}
/*******************************************************************************
** Queue one outbox row per recipient and return the core outcome explicitly.
** Locale is advisory in core and has no Slack outbox equivalent.
*******************************************************************************/
@Override
public SendMessageOutput sendMessage(SendMessageInput input) throws QException
{
if(input.getAttachmentList() != null && !input.getAttachmentList().isEmpty())
{
throw (new QUserFacingException("Slack attachments aren't supported yet"));
return (refused("Slack attachments aren't supported yet"));
}
List<String> addresses = new ArrayList<>();
collect(input.getTo(), addresses);
addresses = addresses.stream().distinct().toList();
if(addresses.isEmpty())
{
throw (new QUserFacingException("A Slack recipient is required"));
return (refused("A Slack recipient is required"));
}
String text = compose(input);
List<String> refusals = new ArrayList<>();
ArrayList<Integer> ids = new ArrayList<>();
boolean queued = false;
for(String address : addresses)
{
SlackSendResult result = SlackService.enqueue(new SlackSendRequest().withTo(address).withText(text));
String key = input.getIdempotencyKey();
if(key != null && addresses.size() > 1)
{
key += ":" + UUID.nameUUIDFromBytes(address.getBytes(StandardCharsets.UTF_8));
}
SlackSendResult result = SlackService.enqueue(new SlackSendRequest().withTo(address).withText(text)
.withTemplateName(input.getTemplateName()).withVariables(input.getVariables()).withIdempotencyKey(key)
.withCategory(input.getCategory()).withSenderName(input.getSenderName())
.withLinkedRecord(input.getLinkedTableName(), input.getLinkedRecordId() == null ? null : input.getLinkedRecordId().toString()));
if(result.status() == SlackSendResult.Status.REFUSED)
{
refusals.addAll(result.reasons());
}
else
{
///////////////////////////////////////////////////////////////////////////////////
// STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6's SendMessageOutput //
// has no status or message id fields, so the outcome is only logged here //
///////////////////////////////////////////////////////////////////////////////////
LOG.debug("Slack message handed to the outbox", logPair("status", result.status().name()), logPair("messageId", result.messageId()));
ids.add(result.messageId());
queued |= result.status() == SlackSendResult.Status.QUEUED;
}
}
return (new SendMessageOutput().withMessageId(ids.isEmpty() ? null : ids.size() == 1 ? ids.get(0) : ids)
.withStatus(!refusals.isEmpty() ? SendMessageStatus.REFUSED : queued ? SendMessageStatus.QUEUED : SendMessageStatus.DUPLICATE)
.withReasons(refusals));
}
if(!refusals.isEmpty())
{
throw (new QUserFacingException(String.join("; ", refusals)));
}
return (new SendMessageOutput());
/*******************************************************************************
** Refusal before any outbox row is created.
*******************************************************************************/
private static SendMessageOutput refused(String reason)
{
return (new SendMessageOutput().withStatus(SendMessageStatus.REFUSED).withReasons(List.of(reason)));
}
@@ -74,6 +74,21 @@ public class SlackPreferences
return (new Section("slack", "Slack", true, connections, List.copyOf(actions)));
}
/*******************************************************************************
** Resolve a visible workspace label without loading token fields.
*******************************************************************************/
public static String workspaceLabel(Integer workspaceId) throws QException
{
List<QRecord> rows = SlackPermissionChecker.workspaceLookup(() -> new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE)
.withFilter(SlackStore.equal("id", workspaceId)).withFieldNamesToInclude(Set.of("teamName", "teamId"))).getRecords());
if(rows.isEmpty())
{
return ("Workspace " + workspaceId);
}
String name = rows.get(0).getValueString("teamName");
return (name == null || name.isBlank() ? rows.get(0).getValueString("teamId") : name);
}
/*******************************************************************************
** Start signed, single-use OAuth for the current user only.
*******************************************************************************/
@@ -1,66 +0,0 @@
/*
* 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.preferences;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.8)
** RC.6 has no preferences SPI. Keep its anticipated registration boundary here,
** and fail explicitly on an incompatible future SPI rather than silently losing
** the section. Connections actions remain available on RC.6.
*******************************************************************************/
public final class SlackPreferencesAdapter
{
public static final String CORE_TYPE = "com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackPreferencesAdapter()
{
}
/*******************************************************************************
** No linkage to missing core classes in the RC.6 artifact.
*******************************************************************************/
public static boolean register(QInstance instance) throws QException
{
try
{
Class<?> type = Class.forName(CORE_TYPE);
Object metadata = type.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance);
type.getMethod("withSection", String.class, String.class, QCodeReference.class)
.invoke(metadata, "slack", "Slack", new QCodeReference(SlackPreferences.class));
return (true);
}
catch(ClassNotFoundException e)
{
return (false);
}
catch(ReflectiveOperationException e)
{
throw (new QException("Update SlackPreferencesAdapter for the installed core preferences SPI.", e));
}
}
}
@@ -0,0 +1,206 @@
/*
* 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.preferences;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.oauth.SlackConnect;
import com.kingsrook.qbits.slack.outbox.SlackSendResult;
import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker;
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionRequest;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
/*******************************************************************************
** Personal Slack connections through the released core preferences SPI.
*******************************************************************************/
public class SlackPreferencesProvider implements UserPreferenceProviderInterface
{
private static final List<String> FIELDS = List.of("workspace", "status", "connectedAt");
/*******************************************************************************
** Register real metadata; configuration errors are intentionally not caught.
*******************************************************************************/
public static void register(QInstance instance)
{
UserPreferencesMetaData.ofOrWithNew(instance).withSection(new SlackSectionMetaData()
.withName("slack").withLabel("Slack").withIcon(new QIcon("forum")).withShowInUserMenu(true)
.withPermissionName(SlackPermissions.CONNECT).withProvider(new QCodeReference(SlackPreferencesProvider.class))
.withList(new UserPreferenceListMetaData().withName("connections").withLabel("Connections").withRowLabel("Connection")
.withField(new QFieldMetaData("workspace", QFieldType.STRING).withLabel("Workspace").withIsEditable(false))
.withField(new QFieldMetaData("status", QFieldType.STRING).withLabel("Status").withIsEditable(false))
.withField(new QFieldMetaData("connectedAt", QFieldType.DATE_TIME).withLabel("Connected at").withIsEditable(false)))
.withAction(action(SlackProcesses.CONNECT, "Connect Slack", UserPreferenceActionScope.SECTION))
.withAction(action(SlackProcesses.INSTALL, "Install for my organization", UserPreferenceActionScope.SECTION))
.withAction(action(SlackProcesses.TEST_MESSAGE, "Send me a test message", UserPreferenceActionScope.ROW))
.withAction(action(SlackProcesses.DISCONNECT, "Disconnect", UserPreferenceActionScope.ROW)
.withIsDestructive(true).withConfirmationText("Disconnect this Slack connection?")));
}
/*******************************************************************************
** Row actions target only the connections list.
*******************************************************************************/
private static UserPreferenceActionMetaData action(String name, String label, UserPreferenceActionScope scope)
{
return (new UserPreferenceActionMetaData().withName(name).withLabel(label).withScope(scope)
.withListName(scope == UserPreferenceActionScope.ROW ? "connections" : null));
}
/*******************************************************************************
** RC.7 has no per-action permission property. Filter a fresh view for the
** current session without mutating shared metadata or other users' actions.
*******************************************************************************/
public static class SlackSectionMetaData extends UserPreferenceSectionMetaData
{
/*******************************************************************************
** With no session, expose all registrations for startup validation.
*******************************************************************************/
@Override
public List<UserPreferenceActionMetaData> getActions()
{
return (QContext.getQSession() == null ? super.getActions() : super.getActions().stream()
.filter(action -> permitted(action.getName())).toList());
}
}
/*******************************************************************************
** Check permissions even for direct provider calls inside core's trusted scope.
*******************************************************************************/
private static boolean permitted(String action)
{
if(!SlackPermissionChecker.has(SlackPermissions.CONNECT))
{
return (false);
}
return (switch(action)
{
case SlackProcesses.CONNECT, SlackProcesses.DISCONNECT -> true;
case SlackProcesses.INSTALL -> SlackPermissionChecker.has(SlackPermissions.INSTALL);
case SlackProcesses.TEST_MESSAGE -> SlackPermissionChecker.has(SlackPermissions.SEND);
default -> false;
});
}
/*******************************************************************************
** Owner-scoped services provide only non-secret connection metadata.
*******************************************************************************/
@Override
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
{
SlackPreferences.Section section = new SlackPreferences().section();
List<UserPreferenceRow> rows = new ArrayList<>();
Set<String> rowActions = section.actions().stream()
.filter(name -> Set.of(SlackProcesses.DISCONNECT, SlackProcesses.TEST_MESSAGE).contains(name))
.collect(Collectors.toSet());
for(QRecord connection : section.connections())
{
String workspace = SlackPreferences.workspaceLabel(connection.getValueInteger("workspaceId"));
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("workspace", workspace);
values.put("status", connection.getValue("status"));
values.put("connectedAt", connection.getValue("connectedAt"));
rows.add(new UserPreferenceRow().withKey(connection.getValueString("id")).withLabel(workspace).withValues(values)
.withReadOnlyFieldNames(FIELDS).withReadOnlyReason("Use the connection actions to make changes.")
.withActionNames("REVOKED".equals(connection.getValueString("status")) ? Set.of() : rowActions));
}
UserPreferenceValues values = new UserPreferenceValues();
values.getLists().put("connections", rows);
return (values);
}
/*******************************************************************************
** Even an empty PATCH is rejected: this section has no editable settings.
*******************************************************************************/
@Override
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
throw (new QBadRequestException("Slack connections: can't be changed. Use the connection actions."));
}
/*******************************************************************************
** RC.7 has no redirect result; return the authorize URL in its message field.
** Row services retain ownership checks even after core has checked the row.
*******************************************************************************/
@Override
public UserPreferenceActionResult runAction(UserPreferenceRequestContext context, UserPreferenceActionRequest request) throws QException
{
String action = request.getActionName();
if(!permitted(action))
{
throw (new QBadRequestException("This action isn't available."));
}
if(SlackProcesses.CONNECT.equals(action) || SlackProcesses.INSTALL.equals(action))
{
return (new UserPreferenceActionResult().withMessage(SlackPreferences.connect(SlackProcesses.INSTALL.equals(action))));
}
Integer id;
try
{
id = Integer.valueOf(request.getRowKey());
}
catch(NumberFormatException e)
{
throw (new QBadRequestException("This action isn't available for this row."));
}
QRecord connection = SlackConnect.visible(SlackTables.CONNECTION, id);
if(!context.getUserId().equals(connection.getValueString("userId")))
{
throw (new QBadRequestException("This action isn't available for this row."));
}
if(SlackProcesses.DISCONNECT.equals(action))
{
SlackConnect.disconnect(id);
return (new UserPreferenceActionResult().withMessage("Slack disconnected."));
}
SlackSendResult result = SlackPreferences.testMessage(id);
if(result.status() == SlackSendResult.Status.REFUSED)
{
throw (new QBadRequestException(String.join("; ", result.reasons())));
}
return (new UserPreferenceActionResult().withMessage("Test message queued.").withFocusRowKey(request.getRowKey()));
}
}
@@ -32,7 +32,6 @@ import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker;
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.preferences.SlackPreferences;
import com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qbits.slack.security.SlackSecurity;
import com.kingsrook.qbits.slack.support.SlackStore;
@@ -297,7 +296,7 @@ class SlackAppTest
}
/*******************************************************************************
** RC.6 skips registration; personal section exposes only permitted actions.
** Personal section exposes only permitted actions.
*******************************************************************************/
@Test
void preferencesAndConnect() throws Exception
@@ -305,7 +304,6 @@ class SlackAppTest
try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl("http://localhost:8099")
.withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "demo-secret")))
{
assertThat(SlackPreferencesAdapter.register(env.instance())).isFalse();
int workspace = env.workspace("T", null, "xoxb-demo", "B");
env.connection("alice", workspace, "UA", null);
env.connection("bob", workspace, "UB", null);
@@ -0,0 +1,139 @@
/*
* 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.Map;
import java.util.Set;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qbits.slack.security.SlackSecurity;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Exercise the released SPI through the same core action used by the UI API.
*******************************************************************************/
class SlackPreferencesTest
{
/*******************************************************************************
** Even administrators see only their own connections; no tokens are exposed.
*******************************************************************************/
@Test
void personalReadOnlyConnections() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, config -> {}))
{
int workspace = env.workspace("T", null, "xoxb-test", "BOT");
int alice = env.connection("alice", workspace, "UA", "xoxp-test");
int bob = env.connection("bob", workspace, "UB", null);
var connectedAt = java.time.Instant.parse("2026-10-14T12:00:00Z");
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", alice).withValue("connectedAt", connectedAt), null);
var session = env.asOwner("alice").withPermissions(SlackPermissions.CONNECT, SlackPermissions.ADMIN);
SlackSecurity.applyToSession(session, "alice", true);
var action = new UserPreferencesAction();
var result = action.getSection("slack");
var rows = result.getValues().getLists().get("connections");
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKey()).isEqualTo(String.valueOf(alice));
assertThat(rows.get(0).getValues()).containsOnlyKeys("workspace", "status", "connectedAt")
.containsEntry("workspace", "Team T").containsEntry("status", "ACTIVE").containsEntry("connectedAt", connectedAt);
assertThat(result.getSection().getLists().get(0).getFields()).allSatisfy(field -> assertThat(field.getIsEditable()).isFalse());
assertThatThrownBy(() -> action.patchSection("slack", Map.of("lists", Map.of("connections",
java.util.List.of(Map.of("key", String.valueOf(alice), "values", Map.of("status", "REVOKED")))))))
.isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed");
assertThatThrownBy(() -> action.patchSection("slack", Map.of())).isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed");
for(String name : Set.of(SlackProcesses.DISCONNECT, SlackProcesses.TEST_MESSAGE))
{
assertThatThrownBy(() -> action.runAction("slack", name, Map.of("rowKey", String.valueOf(bob))))
.isInstanceOf(QBadRequestException.class).hasMessageContaining("isn't available for this row");
}
env.asOwner("bob").withPermissions(SlackPermissions.CONNECT);
assertThat(action.getSection("slack").getValues().getLists().get("connections"))
.extracting(row -> row.getKey()).containsExactly(String.valueOf(bob));
}
}
/*******************************************************************************
** Action visibility is session-specific and forbidden actions cannot be invoked.
*******************************************************************************/
@Test
void permissionFilteredActionsAndAuthorizeUrls() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, config -> config.withPublicBaseUrl("http://localhost:8099")
.withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "demo-secret")))
{
int workspace = env.workspace("T", null, "xoxb-test", "BOT");
int own = env.connection("alice", workspace, "UA", null);
var session = env.asOwner("alice");
session.setPermissions(Set.of(SlackPermissions.CONNECT));
var action = new UserPreferencesAction();
assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName)
.containsExactly(SlackProcesses.CONNECT, SlackProcesses.DISCONNECT);
assertThat(action.getSection("slack").getValues().getLists().get("connections").get(0).getActionNames())
.containsExactly(SlackProcesses.DISCONNECT);
assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.INSTALL, Map.of())).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.TEST_MESSAGE, Map.of("rowKey", String.valueOf(own))))
.isInstanceOf(QNotFoundException.class);
assertThat(action.runAction("slack", SlackProcesses.CONNECT, Map.of()).getMessage()).startsWith("https://slack.com/oauth/v2/authorize?").contains("state=");
session.setPermissions(Set.of(SlackPermissions.CONNECT, SlackPermissions.INSTALL, SlackPermissions.SEND));
assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName)
.contains(SlackProcesses.INSTALL, SlackProcesses.TEST_MESSAGE);
assertThat(action.runAction("slack", SlackProcesses.INSTALL, Map.of()).getMessage()).contains("slack.com/oauth/v2/authorize", "scope=chat");
session.setPermissions(Set.of(SlackPermissions.CONNECT));
assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName)
.doesNotContain(SlackProcesses.INSTALL, SlackProcesses.TEST_MESSAGE);
session.setPermissions(Set.of(SlackPermissions.VIEW));
assertThat(action.listSections()).isEmpty();
assertThatThrownBy(() -> action.getSection("slack")).isInstanceOf(QNotFoundException.class);
}
}
/*******************************************************************************
** Test sends enqueue only; disconnect removes the owner's access and actions.
*******************************************************************************/
@Test
void rowActions() throws Exception
{
try(SlackTestEnv env = SlackTestEnv.create(false, config -> {}))
{
int workspace = env.workspace("T", null, "xoxb-test", "BOT");
int own = env.connection("alice", workspace, "UA", null);
env.asOwner("alice").withPermissions(SlackPermissions.CONNECT);
var action = new UserPreferencesAction();
var body = Map.of("rowKey", String.valueOf(own));
assertThat(action.runAction("slack", SlackProcesses.TEST_MESSAGE, body).getMessage()).contains("queued");
assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(1);
assertThat(env.slack().requests("chat.postMessage")).isEmpty();
var result = action.runAction("slack", SlackProcesses.DISCONNECT, body);
assertThat(result.getSection().getValues().getLists().get("connections").get(0).getValues()).containsEntry("status", "REVOKED");
assertThat(result.getSection().getValues().getLists().get("connections").get(0).getActionNames()).isEmpty();
assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.TEST_MESSAGE, body)).isInstanceOf(QBadRequestException.class);
}
}
}
@@ -22,33 +22,36 @@ 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.api.SlackApiTemplate;
import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProvider;
import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Attachment;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageStatus;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
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;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The core messaging provider: core SendMessageAction with the provider named
** "slack" queues rows for the current user, and its refusals reach the caller
** as user-facing exceptions. The scheduled dispatch process delivers them.
** as structured outcomes. The scheduled dispatch process delivers them.
*******************************************************************************/
class SlackProviderTest
{
@@ -105,7 +108,8 @@ class SlackProviderTest
{
env.asOwner("userA").withPermissions("slack.message.body");
SendMessageOutput output = new SendMessageAction().execute(input(new Party().withAddress("me")));
assertThat(output).isNotNull();
assertThat(output.getStatus()).isEqualTo(SendMessageStatus.QUEUED);
assertThat(output.getMessageId()).isNotNull();
List<QRecord> rows = SlackH2Support.query(SlackTables.MESSAGE);
assertThat(rows).hasSize(1);
@@ -136,33 +140,91 @@ class SlackProviderTest
/*******************************************************************************
** A refusal is a user-facing exception with the reason: an unconnected user,
** a channel the user isn't in, a missing recipient and an attachment.
** Recipients that can be queued still are.
** Refusals use RC.7 status and reasons; partially accepted recipients retain IDs.
*******************************************************************************/
@Test
void testRefusalsAreUserFacingExceptions() throws Exception
void testRefusalsAreStructured() throws Exception
{
env.asOwner("userC");
assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me"))))
.isInstanceOf(QUserFacingException.class).hasMessageContaining("Connect Slack");
assertRefused(input(new Party().withAddress("me")), "Connect Slack");
env.asOwner("userA");
env.slack().members("C2", "UOTHER");
assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("channel:" + workspaceId + "/C2"))))
.isInstanceOf(QUserFacingException.class).hasMessageContaining("aren't a member");
assertThatThrownBy(() -> new SendMessageAction().execute(input(null)))
.isInstanceOf(QUserFacingException.class).hasMessageContaining("recipient");
assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me")).withAttachment(new Attachment())))
.isInstanceOf(QUserFacingException.class).hasMessageContaining("attachments");
assertThatThrownBy(() -> new SendMessageAction().execute(input(new MultiParty().withParty(new Party().withAddress("me")).withParty(new Party().withAddress("garbage")))))
.isInstanceOf(QUserFacingException.class).hasMessageContaining("isn't recognized");
assertRefused(input(new Party().withAddress("channel:" + workspaceId + "/C2")), "aren't a member");
assertRefused(input(null), "recipient");
assertRefused(input(new Party().withAddress("me")).withAttachment(new Attachment()), "attachments");
SendMessageOutput partial = new SendMessageAction().execute(input(new MultiParty().withParty(new Party().withAddress("me"))
.withParty(new Party().withAddress("garbage"))));
assertThat(partial.getStatus()).isEqualTo(SendMessageStatus.REFUSED);
assertThat(partial.getReasons().toString()).contains("isn't recognized");
assertThat(partial.getMessageId()).isNotNull();
assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(1);
}
/*******************************************************************************
** Assert both the status and useful reason through core's real action.
*******************************************************************************/
private void assertRefused(SendMessageInput input, String reason) throws Exception
{
SendMessageOutput result = new SendMessageAction().execute(input);
assertThat(result.getStatus()).isEqualTo(SendMessageStatus.REFUSED);
assertThat(result.getReasons().toString()).contains(reason);
assertThat(result.getMessageId()).isNull();
}
/*******************************************************************************
** Rich fields reach the outbox and repeat sends report the original row ID.
*******************************************************************************/
@Test
void richInputAndDeduplication() throws Exception
{
env.asSystem();
SlackApi.ensureTemplate(new SlackApiTemplate("hello", "Hello", "Hello {{name}}", null,
"[{\"name\":\"name\",\"type\":\"STRING\"}]", "TRANSACTIONAL"));
env.asOwner("userA").withPermissions("slack.message.body");
assertThat(SendMessageAction.capabilities("slack")).containsExactlyInAnyOrder(MessagingProviderCapability.TEMPLATES,
MessagingProviderCapability.IDEMPOTENCY, MessagingProviderCapability.CATEGORIES,
MessagingProviderCapability.LINKED_RECORDS, MessagingProviderCapability.ASYNC_DELIVERY);
SendMessageInput input = input(new Party().withAddress("me")).withTemplateName("hello").withVariables(Map.of("name", "Alice"))
.withIdempotencyKey("rich-input").withCategory("TRANSACTIONAL").withSenderName("Reports")
.withLinkedRecord(SlackTables.WORKSPACE, workspaceId).withLocale("en-US");
SendMessageOutput first = new SendMessageAction().execute(input);
assertThat(first.getStatus()).isEqualTo(SendMessageStatus.QUEUED);
SendMessageOutput second = new SendMessageAction().execute(input);
assertThat(second.getStatus()).isEqualTo(SendMessageStatus.DUPLICATE);
assertThat(second.getMessageId()).isEqualTo(first.getMessageId());
var rows = SlackH2Support.query(SlackTables.MESSAGE);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getValues()).containsEntry("templateName", "hello").containsEntry("category", "TRANSACTIONAL")
.containsEntry("senderName", "Reports").containsEntry("linkedTableName", SlackTables.WORKSPACE)
.containsEntry("linkedRecordId", String.valueOf(workspaceId)).containsEntry("text", "Hello Alice");
}
/*******************************************************************************
** One key must not collapse multiple recipients onto the first recipient's row.
*******************************************************************************/
@Test
void multiPartyIdempotency() throws Exception
{
env.asOwner("userA");
SendMessageInput input = input(new MultiParty().withParty(new Party().withAddress("me"))
.withParty(new Party().withAddress("channel:" + workspaceId + "/C1"))).withIdempotencyKey("multi-input");
SendMessageOutput first = new SendMessageAction().execute(input);
assertThat(first.getStatus()).isEqualTo(SendMessageStatus.QUEUED);
assertThat((List<?>) first.getMessageId()).hasSize(2);
SendMessageOutput second = new SendMessageAction().execute(input);
assertThat(second.getStatus()).isEqualTo(SendMessageStatus.DUPLICATE);
assertThat(second.getMessageId()).isEqualTo(first.getMessageId());
assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(2);
}
/*******************************************************************************
** The provider type is registered with core's dispatcher, the producer
** registers the provider under the configured name, and the dispatch process
@@ -0,0 +1,49 @@
/*
* 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 com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Startup must use the released core preferences SPI.
*******************************************************************************/
class SlackStartupTest
{
/*******************************************************************************
** Produce into a host instance and validate the registered section.
*******************************************************************************/
@Test
void registersPreferencesOnRc7() throws Exception
{
var instance = SlackH2Support.instance(SlackH2Support.backend(), config -> {});
new QInstanceValidator().validate(instance);
assertThat(UserPreferencesMetaData.of(instance).getSections())
.anySatisfy(section ->
{
assertThat(section.getName()).isEqualTo("slack");
assertThat(section.getPermissionName()).isEqualTo("slack.connect");
assertThat(section.getShowInUserMenu()).isTrue();
});
}
}