diff --git a/CHANGELOG.md b/CHANGELOG.md index e3870ec..9fd8e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,5 +7,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- Help bundle `help/qbit-slack.yaml` (stand-in for qqq#1198): every table and the app say what they are for and how rows get created, so an empty table explains itself. +- "Check channels" action on Registered Channels; readiness now writes the channel's Slack name, private, bot is member, archived, purpose and checked time back to the row. +- User group `handle` is resolved from `usergroups.list` during readiness (optional `usergroups:read` scope) and used in the mention (``). +- `SlackTablesGuardrailTest`: every table is used at runtime, seeded or listed as starting empty, and has help. - Design (`docs/DESIGN.md`) and the Slack app manifest (`docs/slack-app-manifest.yaml`). - Repository scaffold per the QRun qbit repository standard: `SlackQBitProducer` and `SlackQBitConfig`. diff --git a/README.md b/README.md index 5f69912..6eb5699 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,24 @@ See [docs/DESIGN.md](docs/DESIGN.md), sections 3 to 14. The tables are prefixed permissions `slack.`. Secrets are environment-variable names only: `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET` and `SLACK_TOKEN_KEY`. +## Setup and starter data + +No Liquibase changelog or host include is needed: the qbit seeds its own data at startup. The three +built-in templates (`notification`, `digest`, `delivery`) are inserted by `SlackTemplates.seed()` +when the host starts and on each readiness run. It is insert-only by name, so edits survive. + +Everything else is host or runtime data, and every table explains itself when empty (help bundle +`help/qbit-slack.yaml`): + +1. An administrator runs "Install for my organization" (Connections). This creates the workspace. +2. Each user runs "Connect Slack" so messages go out as them. +3. Register the channels and user groups processes post to (Registered Channels, User Groups), then + run "Check channels". It records the Slack name, private, bot is member, archived, purpose and + checked time. Readiness refreshes them on a schedule. +4. Review the built-in Templates. + +Messages, Attempts and Interactions are logs and start empty. + ## Adopting in another app Send with core `SendMessageAction` and the provider name `slack`, or deliver output to the `slack` diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..8817d06 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,11 @@ +# Upgrade notes + +## Unreleased + +- No schema change and no Liquibase include. Hosts need do nothing. +- Registered channels now show real status. After upgrading, run "Check channels" on Registered + Channels, or wait for the next readiness run, to fill `slackChannelName`, `isPrivate`, + `botIsMember`, `isArchived`, `description` and `checkedAt`. `slackChannelName` is now set from + Slack, so the label in the outbox follows the channel's current Slack name. +- A user group's `handle` is filled during readiness when the Slack app has the optional + `usergroups:read` scope; mentions then read ``. diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index 553d82b..c1daabe 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -162,6 +162,7 @@ public class SlackQBitProducer implements QBitMetaDataProducer { // Optional health module is absent in this host. } + com.kingsrook.qbits.slack.metadata.SlackHelp.apply(qInstance); return (new MetaDataProducerMultiOutput()); } diff --git a/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java b/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java index 4c4df30..a1a5180 100644 --- a/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java +++ b/src/main/java/com/kingsrook/qbits/slack/app/SlackHomeView.java @@ -107,7 +107,7 @@ public final class SlackHomeView } } return (table(List.of(column("Workspace", "workspace", "html"), column("Check", "check", "text"), column("Status", "status", "text"), column("Fix", "fix", "text")), - rows, "No workspaces installed.")); + rows, "No workspaces installed. An administrator runs Install for my organization on Connections.")); } /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackHelp.java b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackHelp.java new file mode 100644 index 0000000..635d673 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackHelp.java @@ -0,0 +1,131 @@ +/* + * 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.metadata; + + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +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.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Applies help/qbit-slack.yaml (the QQQ help-bundle format) to the Slack app + ** and its slack_* tables: what each table is for and how its rows get created, + ** shown on the table screen and when it is empty. + ** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned QQQ + ** release includes it; the bundle file stays the same. + *******************************************************************************/ +public final class SlackHelp +{ + public static final String RESOURCE = "/help/qbit-slack.yaml"; + public static final String SLOT = "help"; + public static final String APP_NAME = "slack"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SlackHelp() + { + } + + + + /******************************************************************************* + ** The bundle's entries by key, validated (fails startup if malformed). + *******************************************************************************/ + public static Map entries() throws QException + { + try(InputStream stream = SlackHelp.class.getResourceAsStream(RESOURCE)) + { + if(stream == null) + { + throw (new IOException("missing resource")); + } + JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream); + if(!"qbit-slack".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText())) + { + throw (new IOException("invalid bundle header")); + } + Map entries = new LinkedHashMap<>(); + for(JsonNode entry : bundle.path("entries")) + { + String key = entry.path("key").asText(); + String summary = entry.path("summary").asText(); + if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null) + { + throw (new IOException("invalid or duplicate entry: " + key)); + } + } + return (entries); + } + catch(IOException | RuntimeException e) + { + throw (new QException("Could not load " + RESOURCE, e)); + } + } + + + + /******************************************************************************* + ** Apply the help to the app and every slack_* table in the instance. A + ** host's own help on a table is kept (the bundle only fills the slot). + *******************************************************************************/ + public static void apply(QInstance qInstance) throws QException + { + Map entries = entries(); + for(QTableMetaData table : qInstance.getTables().values()) + { + JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT); + if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT))) + { + table.withHelpContent(SLOT, content(entry)); + } + } + QAppMetaData app = qInstance.getApp(APP_NAME); + JsonNode appEntry = entries.get("app:" + APP_NAME + ";slot:" + SLOT); + if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT))) + { + app.withHelpContent(SLOT, content(appEntry)); + } + } + + + + /******************************************************************************* + ** Summary as the first paragraph, then the content, as markdown. + *******************************************************************************/ + static QHelpContent content(JsonNode entry) + { + return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java index 73a485d..bc42c27 100644 --- a/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java +++ b/src/main/java/com/kingsrook/qbits/slack/metadata/SlackTables.java @@ -374,13 +374,13 @@ public final class SlackTables .withField(field("label", QFieldType.STRING).withMaxLength(500)) .withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true)) .withField(field("slackChannelId", QFieldType.STRING).withMaxLength(500)) - .withField(field("slackChannelName", QFieldType.STRING).withMaxLength(500)) - .withField(field("isPrivate", QFieldType.BOOLEAN).withDefaultValue(false)) - .withField(field("botIsMember", QFieldType.BOOLEAN).withDefaultValue(false)) - .withField(field("isArchived", QFieldType.BOOLEAN).withDefaultValue(false)) + .withField(field("slackChannelName", QFieldType.STRING).withMaxLength(500).withIsEditable(false)) + .withField(field("isPrivate", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false)) + .withField(field("botIsMember", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false)) + .withField(field("isArchived", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false)) .withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true)) - .withField(field("description", QFieldType.TEXT)) - .withField(field("checkedAt", QFieldType.DATE_TIME)); + .withField(field("description", QFieldType.TEXT).withIsEditable(false)) + .withField(field("checkedAt", QFieldType.DATE_TIME).withIsEditable(false)); table.withRecordLabelFormatAndFields("%s", "name"); table.withUniqueKey(new UniqueKey("workspaceId", "name")); table.withSection(SectionFactory.defaultT1("id", "name", "label", "workspaceId", "enabled")); @@ -422,7 +422,7 @@ public final class SlackTables QTableMetaData table = base(config, TEMPLATE, "Templates") .withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500)) .withField(field("label", QFieldType.STRING).withMaxLength(500)) - .withField(field("description", QFieldType.TEXT)) + .withField(field("description", QFieldType.TEXT).withIsEditable(false)) .withField(field("category", QFieldType.STRING).withMaxLength(500)) .withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true)) .withField(field("tenantKey", QFieldType.STRING).withMaxLength(500)) diff --git a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java index 0e4eb83..2f8f7b7 100644 --- a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java +++ b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java @@ -63,6 +63,7 @@ public final class SlackProcesses public static final String DISCONNECT = "slack_disconnect"; public static final String UNINSTALL = "slack_uninstall"; public static final String READINESS = "slack_check_readiness"; + public static final String CHECK_CHANNELS = "slack_check_channels"; public static final String MAINTAIN = "slack_readiness"; public static final String DISPATCH = "slack_dispatch"; @@ -101,7 +102,8 @@ public final class SlackProcesses } return (List.of(dispatch, readiness, connectAction(CONNECT, "Connect Slack"), connectAction(INSTALL, "Install for my organization"), action(TEST_MESSAGE, "Send me a test message", SlackTables.CONNECTION).withMinInputRecords(1).withMaxInputRecords(1), action(DISCONNECT, "Disconnect", SlackTables.CONNECTION), - action(UNINSTALL, "Uninstall", SlackTables.WORKSPACE), action(READINESS, "Check readiness", SlackTables.WORKSPACE))); + action(UNINSTALL, "Uninstall", SlackTables.WORKSPACE), action(READINESS, "Check readiness", SlackTables.WORKSPACE), + action(CHECK_CHANNELS, "Check channels", SlackTables.CHANNEL))); } @@ -232,6 +234,11 @@ public final class SlackProcesses { for(QRecord row : input.getRecords()) { + if(CHECK_CHANNELS.equals(input.getProcessName())) + { + checkChannel(row, output); + continue; + } switch(input.getProcessName()) { case TEST_MESSAGE -> @@ -260,6 +267,34 @@ public final class SlackProcesses } } + /******************************************************************************* + ** Check one registered channel. A channel that needs attention is a + ** warning, and a problem with one row (for example its workspace token) + ** is an error for that row only, so it cannot stop the rest. + *******************************************************************************/ + private void checkChannel(QRecord row, RunBackendStepOutput output) throws QException + { + if(!com.kingsrook.qqq.backend.core.context.QContext.getQSession().hasPermission("slack.admin")) + { + throw (new QException("Slack channel check is not permitted")); + } + String name = row.getValueString("name"); + try + { + switch(SlackReadiness.channel(row.getValueInteger("id"))) + { + case USABLE -> lines.add(new ProcessSummaryLine(Status.OK, 1, "Channel " + name + " is ready")); + case NEEDS_ATTENTION -> lines.add(new ProcessSummaryLine(Status.WARNING, 1, "Channel " + name + " needs attention: it is archived, not found, or the bot is not a member")); + default -> lines.add(new ProcessSummaryLine(Status.WARNING, 1, "Slack could not confirm channel " + name + "; try again later")); + } + } + catch(QException e) + { + lines.add(new ProcessSummaryLine(Status.ERROR, 1, "Channel " + name + " could not be checked: its workspace is unavailable")); + } + output.getRecords().add(row); + } + /******************************************************************************* ** Result summary. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java b/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java index 952034d..8185b2f 100644 --- a/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java +++ b/src/main/java/com/kingsrook/qbits/slack/readiness/SlackReadiness.java @@ -130,6 +130,7 @@ public final class SlackReadiness List checks = new ArrayList<>(); boolean good = false; boolean channels = true; + boolean channelsUnknown = false; boolean optionalScopes = false; try { @@ -143,11 +144,23 @@ public final class SlackReadiness { if(Boolean.TRUE.equals(channel.getValueBoolean("enabled"))) { - var info = SlackRuntime.client(config).conversationsInfo(token, channel.getValueString("slackChannelId")); - var json = info.getJson() == null ? null : info.getJson().optJSONObject("channel"); - channels &= info.isOk() && json != null && !json.optBoolean("is_archived", true) && json.optBoolean("is_member", false); + ChannelCheck outcome = checkChannel(config, token, channel); + if(outcome == ChannelCheck.UNKNOWN) + { + ////////////////////////////////////////////////////////////////////// + // Slack did not give a definitive answer (rate limit, outage, auth) // + // so stop checking this workspace and leave the rows as they were // + ////////////////////////////////////////////////////////////////////// + channelsUnknown = true; + break; + } + channels &= outcome == ChannelCheck.USABLE; } } + if(response.getScopes() != null && List.of(response.getScopes().split(",")).contains("usergroups:read")) + { + syncUserGroups(config, token, id); + } } catch(Exception e) { @@ -156,7 +169,8 @@ public final class SlackReadiness } checks.add(new SlackApiReadinessCheck("workspace:" + id, good ? optionalScopes ? "READY" : "WARN" : "ERROR", good && !optionalScopes ? "Workspace ready; optional scopes are missing" : "Workspace identity and required scopes")); - checks.add(check("registeredChannels", channels, "Enabled channels and bot membership")); + checks.add(channelsUnknown && channels ? new SlackApiReadinessCheck("registeredChannels", "WARN", "Slack could not confirm the channels; they will be checked again") + : check("registeredChannels", channels, "Enabled channels and bot membership")); SlackApiReadiness result = result(checks); WORKSPACES.computeIfAbsent(QContext.getQInstance(), key -> new java.util.concurrent.ConcurrentHashMap<>()).put(id, result); SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("readinessStatus", result.ready() ? "OK" : "FAIL") @@ -164,6 +178,97 @@ public final class SlackReadiness return (result); } + /******************************************************************************* + ** What a channel check found. UNKNOWN means Slack gave no definitive answer + ** (rate limit, timeout, outage, invalid auth), so nothing was written. + *******************************************************************************/ + public enum ChannelCheck + { + USABLE, NEEDS_ATTENTION, UNKNOWN + } + + /******************************************************************************* + ** Check one registered channel against Slack and, on a definitive answer, + ** write what Slack says back to its row (name, privacy, bot membership, + ** archived, purpose, checked time). Definitive: the channel came back, or + ** Slack said channel_not_found or not_in_channel (recorded as bot not a + ** member). Anything else leaves the row alone. + *******************************************************************************/ + static ChannelCheck checkChannel(SlackQBitConfig config, String token, QRecord channel) throws QException + { + var info = SlackRuntime.client(config).conversationsInfo(token, channel.getValueString("slackChannelId")); + var json = info.getJson() == null ? null : info.getJson().optJSONObject("channel"); + boolean ok = info.isOk() && json != null; + boolean gone = !info.isOk() && info.getRetryAfterSeconds() == null && info.getHttpStatus() > 0 && info.getHttpStatus() < 500 + && ("channel_not_found".equals(info.getError()) || "not_in_channel".equals(info.getError())); + if(!ok && !gone) + { + return (ChannelCheck.UNKNOWN); + } + QRecord update = new QRecord().withValue("id", channel.getValueInteger("id")).withValue("checkedAt", SlackRuntime.clock().instant()); + if(ok) + { + String purpose = json.optJSONObject("purpose") == null ? "" : json.optJSONObject("purpose").optString("value", ""); + String topic = json.optJSONObject("topic") == null ? "" : json.optJSONObject("topic").optString("value", ""); + update.withValue("isPrivate", json.optBoolean("is_private", false)).withValue("isArchived", json.optBoolean("is_archived", false)) + .withValue("botIsMember", json.optBoolean("is_member", false)).withValue("description", purpose.isBlank() ? topic.isBlank() ? null : topic : purpose); + if(!json.optString("name", "").isBlank()) + { + update.withValue("slackChannelName", json.getString("name")); + } + } + else + { + update.withValue("botIsMember", false); + } + SlackStore.update(SlackTables.CHANNEL, update, null); + return (ok && !json.optBoolean("is_archived", false) && json.optBoolean("is_member", false) ? ChannelCheck.USABLE : ChannelCheck.NEEDS_ATTENTION); + } + + /******************************************************************************* + ** The "Check channels" action: check the given registered channel with its + ** workspace bot token, as the signed-in administrator. A token problem is + ** thrown so the action can report it for this row only. + *******************************************************************************/ + public static ChannelCheck channel(Integer id) throws QException + { + QRecord channel = SlackConnect.visible(SlackTables.CHANNEL, id); + SlackQBitConfig config = SlackRuntime.config(); + return (checkChannel(config, SlackTokens.botToken(config, channel.getValueInteger("workspaceId")), channel)); + } + + /******************************************************************************* + ** Resolve each enabled registered user group's @handle from usergroups.list + ** and keep it on the row, so mentions show the handle. Best effort: a + ** failure leaves the rows as they were. + *******************************************************************************/ + static void syncUserGroups(SlackQBitConfig config, String token, Integer workspaceId) throws QException + { + var response = SlackRuntime.client(config).usergroupsList(token); + var array = response.isOk() && response.getJson() != null ? response.getJson().optJSONArray("usergroups") : null; + if(array == null) + { + return; + } + Map handles = new java.util.HashMap<>(); + for(int i = 0; i < array.length(); i++) + { + var group = array.optJSONObject(i); + if(group != null && !group.optString("id", "").isBlank()) + { + handles.put(group.getString("id"), group.optString("handle", "")); + } + } + for(QRecord row : SlackStore.queryAsCurrent(SlackTables.USER_GROUP, SlackStore.equal("workspaceId", workspaceId))) + { + String handle = handles.get(row.getValueString("slackUsergroupId")); + if(handle != null && !handle.isBlank() && !handle.equals(row.getValueString("handle"))) + { + SlackStore.update(SlackTables.USER_GROUP, new QRecord().withValue("id", row.getValueInteger("id")).withValue("handle", handle), null); + } + } + } + /******************************************************************************* ** Verify only a visible connection's own token and Slack identity. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorizer.java b/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorizer.java index af71f24..40adee3 100644 --- a/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorizer.java +++ b/src/main/java/com/kingsrook/qbits/slack/security/SlackAuthorizer.java @@ -405,7 +405,8 @@ public class SlackAuthorizer { return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't linked to a Slack user group yet")); } - mention = " "; + String handle = row.getValueString("handle"); + mention = " "; } } diff --git a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java index ab0dca6..4cc5452 100644 --- a/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java +++ b/src/main/java/com/kingsrook/qbits/slack/transport/SlackClient.java @@ -293,6 +293,17 @@ public class SlackClient + /******************************************************************************* + ** usergroups.list: the workspace's user groups (id, handle), for resolving + ** a registered group's @handle. Needs the optional usergroups:read scope. + *******************************************************************************/ + public SlackResponse usergroupsList(String token) + { + return (get(token, "usergroups.list", new LinkedHashMap<>())); + } + + + /******************************************************************************* ** One page of users.conversations: the channels the token's user is in. *******************************************************************************/ diff --git a/src/main/resources/help/qbit-slack.yaml b/src/main/resources/help/qbit-slack.yaml new file mode 100644 index 0000000..4671532 --- /dev/null +++ b/src/main/resources/help/qbit-slack.yaml @@ -0,0 +1,53 @@ +bundle: qbit-slack +version: 1 +layer: QBIT +# Help for the Slack app and every slack_* table, in the QQQ help-bundle format +# (qqq#1198). Each table entry says what the table is for and how its rows get +# created, so an empty table explains itself. SlackHelp applies it until the core +# HelpBundles registry ships in a QQQ release. +# Summaries stay under 120 characters (SlackTablesGuardrailTest). +entries: + - key: app:slack;slot:help + summary: "Send Slack messages from QQQ through a durable outbox, as the person who sent them." + content: | + Set up in this order: 1) Install the Slack app for your organization (Connections, "Install for my organization") so a Workspace row exists; 2) each person uses "Connect Slack" so messages go out as them; 3) register the channels and user groups your processes post to, then run "Check channels"; 4) review the built-in Templates. Every send is queued in Messages and retried; Attempts and Interactions are the audit trail. The Readiness section on the home shows what still needs attention. + + - key: table:slack_workspace;slot:help + summary: "A Slack workspace this system is installed in, with its bot token status and readiness." + content: | + **How rows get created:** an administrator runs "Install for my organization" (Connections) and approves the app in Slack; the OAuth callback creates the row and stores the bot token encrypted. Nothing is typed in by hand. Use "Check readiness" to re-check the bot token, scopes and registered channels, and "Uninstall" to remove the app. + + - key: table:slack_connection;slot:help + summary: "A person's own link to a workspace, so their Slack messages are sent as them." + content: | + **How rows get created:** each user runs "Connect Slack" and approves access in Slack; the OAuth callback creates the row and stores their token encrypted. An administrator can see everyone's. "Send me a test message" checks it works, and "Disconnect" revokes it. A workspace must be installed first. + + - key: table:slack_channel;slot:help + summary: "A Slack channel registered under a short name, so processes can post to it and see its status." + content: | + **How rows get created:** an administrator adds one with New: pick the workspace, give it a short name that senders use, and paste the Slack channel ID (open the channel in Slack, then About, then the ID at the bottom). Invite the bot with /invite @YourApp, then run "Check channels": it fills in the Slack name, private, bot is member, archived, purpose and checked time. Readiness re-checks enabled channels on a schedule, and messages are sent only to enabled channels the bot can post to. + + - key: table:slack_user_group;slot:help + summary: "A Slack user group registered under a short name, mentioned when a message goes to the group." + content: | + **How rows get created:** an administrator adds one with New: pick the workspace, give it a short name, paste the Slack user group ID (starts with S) and the ID of the channel where the group is mentioned. The handle is filled in by Check readiness when the app has the optional usergroups:read scope, and then shows in the mention as @handle. A group without a user group ID or channel cannot be sent to. + + - key: table:slack_template;slot:help + summary: "Message templates (text and Slack blocks) with declared variables, used by name when sending." + content: | + **How rows get created:** the qbit adds three built-ins at startup and on each readiness run: notification, digest and delivery (never overwritten once you edit them). Administrators add their own with New: variables must be declared, links must be URLs, and nothing classified as PHI or PII is allowed. A sender names the template and passes variables. + + - key: table:slack_message;slot:help + summary: "The outbox: every Slack message queued, sent, retried or dead-lettered, with who sent it." + content: | + **How rows get created:** automatically. Anything that sends to Slack (a process, an alert, a delivery destination, a test message) queues a row here first as its owner; the dispatcher then posts it and records the outcome. It starts empty on a new installation and is read-only history; nothing is typed in by hand. + + - key: table:slack_message_attempt;slot:help + summary: "One row per delivery attempt of a message: outcome, Slack error and timing." + content: | + **How rows get created:** automatically, by the dispatcher, each time it tries to post a message. Use it to see why a message is retrying or failed. It is audit history and is never edited. + + - key: table:slack_interaction;slot:help + summary: "Audit of clicks on message buttons and Slack events, and what each one did." + content: | + **How rows get created:** automatically, when someone clicks a button on a Slack message or Slack sends a revocation event to the callback URL. Each row records who acted, the action and the outcome (the action runs as the clicking user). It is audit history and is never edited. diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackChannelStatusTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackChannelStatusTest.java new file mode 100644 index 0000000..63efb9d --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackChannelStatusTest.java @@ -0,0 +1,233 @@ +/* + * 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.metadata.SlackTables; +import com.kingsrook.qbits.slack.oauth.SlackConnect; +import com.kingsrook.qbits.slack.outbox.SlackSendRequest; +import com.kingsrook.qbits.slack.outbox.SlackService; +import com.kingsrook.qbits.slack.readiness.SlackReadiness; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The registered channel's status fields and the user group's handle are + ** written from what Slack says, so the screens show real status. + *******************************************************************************/ +class SlackChannelStatusTest +{ + private static final String OK_AUTH = "{\"ok\":true,\"team_id\":\"T1\"}"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + private SlackTestEnv environment() throws Exception + { + return (SlackTestEnv.create(false, c -> c.withSecretResolver(n -> "SLACK_TOKEN_KEY".equals(n) ? SlackTokenCipherTest.KEY_ONE : "test"))); + } + + + + /******************************************************************************* + ** Readiness persists name, privacy, membership, archived, purpose and time. + *******************************************************************************/ + @Test + void readinessWritesChannelStatusBack() throws Exception + { + try(SlackTestEnv env = environment()) + { + int workspace = env.workspace("T1", null, "xoxb-test", "UBOT"); + int channel = env.channel(workspace, "ops", "C1", null); + assertThat(env.row(SlackTables.CHANNEL, channel).getValueInstant("checkedAt")).isNull(); + + env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES))); + env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{\"name\":\"ops-alerts\",\"is_private\":true," + + "\"is_archived\":false,\"is_member\":true,\"purpose\":{\"value\":\"Operations alerts\"}}}")); + assertThat(SlackReadiness.workspace(workspace).checks()).anyMatch(c -> c.name().equals("registeredChannels") && c.status().equals("READY")); + + QRecord row = env.row(SlackTables.CHANNEL, channel); + assertThat(row.getValueString("slackChannelName")).isEqualTo("ops-alerts"); + assertThat(row.getValueBoolean("isPrivate")).isTrue(); + assertThat(row.getValueBoolean("botIsMember")).isTrue(); + assertThat(row.getValueBoolean("isArchived")).isFalse(); + assertThat(row.getValueString("description")).isEqualTo("Operations alerts"); + assertThat(row.getValueInstant("checkedAt")).isNotNull(); + } + } + + + + /******************************************************************************* + ** An archived channel, or one the bot has not joined, is recorded as such. + *******************************************************************************/ + @Test + void unavailableChannelsAreRecorded() throws Exception + { + try(SlackTestEnv env = environment()) + { + int workspace = env.workspace("T1", null, "xoxb-test", "UBOT"); + int channel = env.channel(workspace, "old", "C1", "old"); + for(String flags : List.of("\"is_archived\":true,\"is_member\":true", "\"is_archived\":false,\"is_member\":false")) + { + env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES))); + env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{" + flags + "}}")); + SlackReadiness.workspace(workspace); + QRecord row = env.row(SlackTables.CHANNEL, channel); + assertThat(row.getValueBoolean("isArchived")).isEqualTo(flags.contains("\"is_archived\":true")); + assertThat(row.getValueBoolean("botIsMember")).isEqualTo(flags.contains("\"is_member\":true")); + assertThat(row.getValueString("slackChannelName")).as("kept when Slack sends no name").isEqualTo("old"); + } + } + } + + + + /******************************************************************************* + ** The "Check channels" action checks one selected channel, even a disabled + ** one, and a channel Slack cannot return is recorded as not joined. + *******************************************************************************/ + @Test + void checkChannelsActionChecksOneChannel() throws Exception + { + try(SlackTestEnv env = environment()) + { + int workspace = env.workspace("T1", null, "xoxb-test", "UBOT"); + int channel = env.channel(workspace, "ops", "C1", null); + env.set(SlackTables.CHANNEL, channel, "enabled", false); + + env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{\"name\":\"ops\",\"is_member\":true}}")); + assertThat(SlackReadiness.channel(channel)).isEqualTo(SlackReadiness.ChannelCheck.USABLE); + assertThat(env.row(SlackTables.CHANNEL, channel).getValueBoolean("botIsMember")).isTrue(); + + env.slack().script("conversations.info", FakeSlackServer.Reply.error("channel_not_found")); + assertThat(SlackReadiness.channel(channel)).isEqualTo(SlackReadiness.ChannelCheck.NEEDS_ATTENTION); + QRecord row = env.row(SlackTables.CHANNEL, channel); + assertThat(row.getValueBoolean("botIsMember")).isFalse(); + assertThat(row.getValueInstant("checkedAt")).isNotNull(); + } + } + + + + /******************************************************************************* + ** Readiness resolves a user group's handle; the mention then carries it. + *******************************************************************************/ + @Test + void userGroupHandleIsResolvedAndMentioned() throws Exception + { + try(SlackTestEnv env = environment()) + { + int workspace = env.workspace("T1", null, "xoxb-test", "UBOT"); + int group = env.userGroup(workspace, "oncall", "S123", "C1"); + env.connection("owner", workspace, "U1", "xoxp-test"); + env.slack().members("C1", "U1", "UBOT"); + env.slack().name("C1", "ops"); + + env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES + ",usergroups:read"))); + env.slack().script("usergroups.list", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"usergroups\":[{\"id\":\"S123\",\"handle\":\"oncall-team\"},{\"id\":\"S999\",\"handle\":\"other\"}]}")); + SlackReadiness.workspace(workspace); + assertThat(env.row(SlackTables.USER_GROUP, group).getValueString("handle")).isEqualTo("oncall-team"); + assertThat(sentText(env)).startsWith(" "); + + env.set(SlackTables.USER_GROUP, group, "handle", "bad|handle>"); + assertThat(sentText(env)).as("an unsafe handle is ignored").startsWith(" "); + env.set(SlackTables.USER_GROUP, group, "handle", null); + assertThat(sentText(env)).startsWith(" "); + } + } + + + + /******************************************************************************* + ** A rate limit, outage or invalid auth is not an answer: the rows keep what + ** they had, the loop stops, and the check is a warning, not a failure. + *******************************************************************************/ + @Test + void inconclusiveAnswersLeaveTheRowsAlone() throws Exception + { + try(SlackTestEnv env = environment()) + { + int workspace = env.workspace("T1", null, "xoxb-test", "UBOT"); + int first = env.channel(workspace, "a", "C1", "a"); + int second = env.channel(workspace, "b", "C2", "b"); + env.set(SlackTables.CHANNEL, first, "botIsMember", true); + env.set(SlackTables.CHANNEL, second, "botIsMember", true); + List replies = List.of(new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", Map.of("Retry-After", "30")), + new FakeSlackServer.Reply(500, "oops", Map.of()), FakeSlackServer.Reply.error("invalid_auth")); + for(FakeSlackServer.Reply reply : replies) + { + env.slack().clearRequests(); + env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES))); + env.slack().script("conversations.info", reply); + var result = SlackReadiness.workspace(workspace); + assertThat(result.checks()).anyMatch(c -> c.name().equals("registeredChannels") && c.status().equals("WARN")); + assertThat(result.ready()).isTrue(); + assertThat(env.slack().requests("conversations.info")).as("stops after the first inconclusive answer").hasSize(1); + for(int id : List.of(first, second)) + { + QRecord row = env.row(SlackTables.CHANNEL, id); + assertThat(row.getValueBoolean("botIsMember")).isTrue(); + assertThat(row.getValueInstant("checkedAt")).isNull(); + } + } + env.slack().script("conversations.info", FakeSlackServer.Reply.of(429, "{\"ok\":false}")); + assertThat(SlackReadiness.channel(first)).isEqualTo(SlackReadiness.ChannelCheck.UNKNOWN); + assertThat(env.row(SlackTables.CHANNEL, first).getValueBoolean("botIsMember")).isTrue(); + } + } + + /******************************************************************************* + ** The handle needs only usergroups:read, not chat:write.customize. + *******************************************************************************/ + @Test + void handleSyncNeedsOnlyUsergroupsRead() throws Exception + { + try(SlackTestEnv env = environment()) + { + int workspace = env.workspace("T1", null, "xoxb-test", "UBOT"); + int group = env.userGroup(workspace, "oncall", "S123", "C1"); + env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES + ",usergroups:read"))); + env.slack().script("usergroups.list", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"usergroups\":[{\"id\":\"S123\",\"handle\":\"oncall-team\"}]}")); + SlackReadiness.workspace(workspace); + assertThat(env.row(SlackTables.USER_GROUP, group).getValueString("handle")).isEqualTo("oncall-team"); + } + } + + + /******************************************************************************* + ** The stored text of a message to the user group, queued as its owner. + *******************************************************************************/ + private String sentText(SlackTestEnv env) throws Exception + { + env.asOwner("owner"); + var result = SlackService.enqueue(new SlackSendRequest().withTo("group:oncall").withText("page")); + assertThat(result.messageId()).isNotNull(); + return (env.row(SlackTables.MESSAGE, result.messageId()).getValueString("text")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackTablesGuardrailTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackTablesGuardrailTest.java new file mode 100644 index 0000000..9219b7e --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackTablesGuardrailTest.java @@ -0,0 +1,222 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import com.kingsrook.qbits.slack.metadata.SlackHelp; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qbits.slack.template.SlackTemplates; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +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.help.QHelpContent; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Guardrail (owner requirement 2026-10-14: "all tables should be populated and + ** the qbit should be fully using them"). For every slack_* table: + ** - runtime code (not just its metadata or home screen) reads or writes it; + ** - it is either seeded by the qbit at startup, or listed here with why it + ** starts empty; + ** - it has bundle help that says what it is for and how its rows get created + ** (shown on the table screen and when it is empty). + ** A new table fails this test until it is wired, seeded or explained. + *******************************************************************************/ +class SlackTablesGuardrailTest +{ + private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "slack"); + + /******************************************************************************* + ** Tables the qbit seeds itself (SlackTemplates.seed: idempotent, by name). + *******************************************************************************/ + static final Map SEEDED = Map.of("slack_template", "SlackTemplates.seed(): notification, digest and delivery"); + + /******************************************************************************* + ** Tables that legitimately start empty, and where their rows come from. + *******************************************************************************/ + static final Map STARTS_EMPTY = Map.ofEntries( + Map.entry("slack_workspace", "OAuth install: an administrator runs 'Install for my organization'"), + Map.entry("slack_connection", "OAuth connect: each user runs 'Connect Slack'"), + Map.entry("slack_channel", "host data: the channels a host posts to, with Slack IDs; 'Check channels' fills their status"), + Map.entry("slack_user_group", "host data: the user groups a host mentions, with Slack IDs"), + Map.entry("slack_message", "runtime log: every queued send"), + Map.entry("slack_message_attempt", "runtime log: every delivery attempt"), + Map.entry("slack_interaction", "runtime log: every button click or Slack event")); + + + + /******************************************************************************* + ** Every table is used by runtime code. + *******************************************************************************/ + @Test + void everyTableIsUsedAtRuntime() throws Exception + { + Map constantByTable = constantByTable(); + assertThat(constantByTable.keySet()).containsAll(SlackTables.ALL); + List runtimeSource = new ArrayList<>(); + try(Stream files = Files.walk(MAIN)) + { + files.filter(p -> p.toString().endsWith(".java") && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app"))) + .forEach(p -> runtimeSource.add(read(p))); + } + for(String table : SlackTables.ALL) + { + String constant = constantByTable.get(table); + Pattern use = Pattern.compile("SlackTables\\." + constant + "\\b"); + assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find())) + .as(table + " (SlackTables." + constant + ") must be read or written by runtime code, not only declared").isTrue(); + } + } + + + + /******************************************************************************* + ** Every table is seeded or explained. + *******************************************************************************/ + @Test + void everyTableIsSeededOrExplained() + { + for(String table : SlackTables.ALL) + { + assertThat(SEEDED.containsKey(table) || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue(); + assertThat(SEEDED.containsKey(table) && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both").isFalse(); + } + assertThat(SlackTables.ALL).containsAll(STARTS_EMPTY.keySet()).containsAll(SEEDED.keySet()); + } + + + + /******************************************************************************* + ** The seed fills the template table, running it again adds nothing, and an + ** administrator's edit survives. + *******************************************************************************/ + @Test + void seedIsInsertOnly() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> + { + })) + { + env.asSystem(); + SlackTemplates.seed(); + List first = SlackStore.queryAsCurrent(SlackTables.TEMPLATE, new QQueryFilter()); + assertThat(first).extracting(r -> r.getValueString("name")).containsExactlyInAnyOrder("notification", "digest", "delivery"); + assertThat(first).allSatisfy(r -> assertThat(r.getValueBoolean("enabled")).isTrue()); + + int id = first.stream().filter(r -> "digest".equals(r.getValueString("name"))).findFirst().orElseThrow().getValueInteger("id"); + SlackStore.update(SlackTables.TEMPLATE, new QRecord().withValue("id", id).withValue("label", "Edited by an admin"), null); + SlackTemplates.seed(); + assertThat(SlackStore.queryAsCurrent(SlackTables.TEMPLATE, new QQueryFilter())).hasSize(3); + assertThat(SlackStore.get(SlackTables.TEMPLATE, id).getValueString("label")).isEqualTo("Edited by an admin"); + } + } + + + + /******************************************************************************* + ** Every table and the app have bundle help that says how rows get created. + *******************************************************************************/ + @Test + void everyTableHasEmptyStateHelp() throws Exception + { + QInstance instance = SlackH2Support.setup(c -> + { + }); + assertThat(SlackHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120)); + for(String table : SlackTables.ALL) + { + List help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(SlackHelp.SLOT); + assertThat(help).as(table + " table help").isNotEmpty(); + assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created"); + } + assertThat(instance.getApp(SlackHelp.APP_NAME).getHelpContent()).containsKey(SlackHelp.SLOT); + assertThat(SlackHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null, + "help names only real tables"); + assertThat(SlackHelp.entries().get("table:slack_channel;slot:help").path("content").asText()).contains("Check channels"); + } + + + + /******************************************************************************* + ** Help a host put in the slot first is never replaced by the bundle. + *******************************************************************************/ + @Test + void hostHelpIsKept() throws Exception + { + QInstance instance = SlackH2Support.setup(c -> + { + }); + QHelpContent host = new QHelpContent().withContentAsMarkdown("Host help for channels"); + instance.getTable(SlackTables.CHANNEL).getHelpContent().put(SlackHelp.SLOT, new ArrayList<>(List.of(host))); + instance.getApp(SlackHelp.APP_NAME).getHelpContent().put(SlackHelp.SLOT, new ArrayList<>(List.of(host))); + SlackHelp.apply(instance); + assertThat(instance.getTable(SlackTables.CHANNEL).getHelpContent().get(SlackHelp.SLOT)).containsExactly(host); + assertThat(instance.getApp(SlackHelp.APP_NAME).getHelpContent().get(SlackHelp.SLOT)).containsExactly(host); + assertThat(instance.getTable(SlackTables.MESSAGE).getHelpContent().get(SlackHelp.SLOT).get(0).getContent()).contains("How rows get created"); + } + + + + /******************************************************************************* + ** SlackTables declares CONSTANT = "slack_x"; table name to constant name. + *******************************************************************************/ + private static Map constantByTable() + { + Map rs = new HashMap<>(); + Matcher m = Pattern.compile("public static final String ([A-Z_]+)\\s*=\\s*\"(slack_[a-z_]+)\";").matcher(read(MAIN.resolve("metadata").resolve("SlackTables.java"))); + while(m.find()) + { + rs.put(m.group(2), m.group(1)); + } + return (rs); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String read(Path path) + { + try + { + return (Files.readString(path, StandardCharsets.UTF_8)); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } + +}