Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fd036f58 | ||
|
|
33ba265215 | ||
|
|
7a0fd3762a | ||
|
|
2682c2fa94 | ||
|
|
42aaf3c9c8 | ||
|
|
a40eade67b | ||
|
|
f0b70d7da7 | ||
|
|
4dcabdffed | ||
|
|
de09fc27e9 | ||
|
|
fb4ae6db07
|
||
|
|
2f7025ef28 | ||
|
|
33d993f414
|
||
|
|
87a2218ef6 | ||
|
|
56b6f48a7b
|
||
|
|
80083044a8 | ||
|
|
1811bbf211
|
||
|
|
070e2c95d9 | ||
|
|
7f71fc0566
|
||
|
|
91b24cb986 | ||
|
|
36def54b8b
|
||
|
|
c28210353e | ||
|
|
37524048ba
|
||
|
|
1ddbf470de
|
||
|
|
c1064601da | ||
|
|
d8063498a2
|
||
|
|
4bc330d96b
|
No files matched your search
+10
-61
@@ -1,65 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [ /^v\d+\.\d+\.\d+$/ ]
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release-no-integration
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -6,6 +6,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- QBit metadata now reports the Maven artifact version, including release versions, from a filtered properties resource.
|
||||
|
||||
### Added
|
||||
- Complete host-neutral in-app help bundle with live labels and core `HelpBundleAssertions` coverage; registered through QQQ 4.1.0-RC.7 `HelpBundles`, replacing the temporary loader.
|
||||
- "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 (`<!subteam^ID|@handle>`).
|
||||
- `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`.
|
||||
@@ -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
|
||||
@@ -31,12 +31,43 @@ 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`
|
||||
destination. Don't import this qbit's internal packages; `com.kingsrook.qbits.slack.api` is the only
|
||||
public surface.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [help/qbit-slack.yaml](src/main/resources/help/qbit-slack.yaml)
|
||||
with QQQ's help bundle registry. Hosts with help content enabled sync it at startup;
|
||||
registration is harmless without a `helpContent` table. Live references use the host's current labels.
|
||||
|
||||
To override text, register a separate bundle with `layer: HOST` and the same entry keys
|
||||
(and roles) using `HelpBundles.register(qInstance, "help/your-host.yaml")`.
|
||||
HOST entries take precedence over QBIT entries; user edits are preserved by core sync.
|
||||
|
||||
`SlackTablesGuardrailTest.everyTableHasEmptyStateHelp` runs `HelpBundleAssertions` over
|
||||
Slack's produced metadata to check coverage, keys, summaries and live references during `verify`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
|
||||
+11
@@ -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 `<!subteam^ID|@handle>`.
|
||||
+20
-8
@@ -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`)
|
||||
|
||||
@@ -429,6 +430,11 @@ user with `users.lookupByEmail`, but only when the IdP marked the session email
|
||||
results, and a user sees their own in Preferences.
|
||||
- **Checks run:** every `readinessIntervalMinutes` (15), on the **Check readiness** action, and from
|
||||
`SlackApi.readiness*`. Messages never include token values or exception text.
|
||||
- **Not configured is not a failure.** With no Slack app secrets at all, the `app` check is NOT_CONFIGURED and
|
||||
health is UNKNOWN (HTTP 200) with `configured: false`. Before the first readiness run, health is also UNKNOWN.
|
||||
Only a configured-but-broken app (some secrets missing, or an invalid token key) is DOWN. Slack is optional, so an
|
||||
unset Slack integration must never fail a readiness probe; "not configured" shows in the readiness and
|
||||
central-health views only.
|
||||
- **Health:** when `qqq-middleware-health` is present, the qbit registers `HealthCheckMetaData` `slack`.
|
||||
- It reports the stored deployment-level readiness (`app` and `outbox`), without calling Slack on each
|
||||
probe.
|
||||
@@ -529,7 +535,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 +706,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.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.1.0-RC.2</revision>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -120,7 +120,35 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-slack.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-slack.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -213,7 +241,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>
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData;
|
||||
import com.kingsrook.qbits.slack.delivery.SlackAlerts;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
@@ -31,6 +34,7 @@ import com.kingsrook.qbits.slack.security.SlackSecurity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
|
||||
@@ -42,13 +46,41 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-slack";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private SlackQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered properties resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = SlackQBitProducer.class.getClassLoader().getResourceAsStream("qbit-slack.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing qbit-slack.properties; build with Maven to supply the artifact version"));
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw (new IllegalStateException("Missing or unfiltered version in qbit-slack.properties; build with Maven"));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not read artifact version from qbit-slack.properties", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
@@ -150,7 +182,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
|
||||
@@ -162,11 +197,31 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
{
|
||||
// Optional health module is absent in this host.
|
||||
}
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-slack.yaml");
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -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:<id>,
|
||||
** channel:<workspace>/<channel>, channel:<name>, #<name>,
|
||||
** group:<name>); 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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -64,9 +64,29 @@ public class SlackHealth implements HealthIndicator
|
||||
public HealthCheckResult check(QInstance instance)
|
||||
{
|
||||
var readiness = SlackReadiness.stored(instance);
|
||||
boolean appFailed = readiness.checks().stream().anyMatch(c -> "app".equals(c.name()) && "ERROR".equals(c.status()));
|
||||
HealthStatus status = appFailed ? HealthStatus.DOWN : readiness.ready() ? HealthStatus.UP : HealthStatus.DEGRADED;
|
||||
String appStatus = readiness.checks().stream().filter(c -> "app".equals(c.name())).map(c -> c.status()).findFirst().orElse("UNKNOWN");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Slack is optional. Not configured (no secrets at all) or not yet checked //
|
||||
// is UNKNOWN, which never fails a readiness probe (HTTP 200). Only a //
|
||||
// configured-but-broken app (partial or invalid secrets) is DOWN. //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
if(SlackReadiness.NOT_CONFIGURED.equals(appStatus))
|
||||
{
|
||||
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN)
|
||||
.withDetail("configured", false)
|
||||
.withDetail("message", "Slack is not configured")
|
||||
.withDetail("checks", readiness.checks()));
|
||||
}
|
||||
if("UNKNOWN".equals(appStatus))
|
||||
{
|
||||
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN)
|
||||
.withDetail("message", "Slack readiness has not run yet")
|
||||
.withDetail("checks", readiness.checks()));
|
||||
}
|
||||
HealthStatus status = "ERROR".equals(appStatus) ? HealthStatus.DOWN : readiness.ready() ? HealthStatus.UP : HealthStatus.DEGRADED;
|
||||
return (new HealthCheckResult().withStatus(status)
|
||||
.withDetail("configured", true)
|
||||
.withDetail("checks", readiness.checks()));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
*******************************************************************************/
|
||||
public final class SlackReadiness
|
||||
{
|
||||
public static final String NOT_CONFIGURED = "NOT_CONFIGURED";
|
||||
|
||||
private static final Map<QInstance, SlackApiReadiness> STORED = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private static final Map<QInstance, Map<Integer, SlackApiReadiness>> WORKSPACES = java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>());
|
||||
@@ -87,6 +89,17 @@ public final class SlackReadiness
|
||||
app = false;
|
||||
}
|
||||
List<SlackApiReadinessCheck> checks = new ArrayList<>();
|
||||
if(!app && !anySecretPresent(config))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// no SLACK_* secret at all: Slack is simply not set up in this //
|
||||
// deployment. That is a state, not a failure; health reports UNKNOWN. //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
checks.add(new SlackApiReadinessCheck("app", NOT_CONFIGURED, "Slack is not configured: no Slack app secrets are set"));
|
||||
SlackApiReadiness result = new SlackApiReadiness(false, checks);
|
||||
STORED.put(instance, result);
|
||||
return (result);
|
||||
}
|
||||
checks.add(check("app", app, "Application configuration"));
|
||||
boolean outbox = true;
|
||||
if(config != null && QContext.getQInstance() == instance)
|
||||
@@ -112,6 +125,33 @@ public final class SlackReadiness
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** True when at least one of the app's secret names resolves to a value.
|
||||
** Presence only; values are never retained.
|
||||
*******************************************************************************/
|
||||
static boolean anySecretPresent(SlackQBitConfig config)
|
||||
{
|
||||
if(config == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
for(String name : java.util.Arrays.asList(config.getClientIdEnvName(), config.getClientSecretEnvName(), config.getSigningSecretEnvName(), config.getTokenKeyEnvName()))
|
||||
{
|
||||
try
|
||||
{
|
||||
SlackSecrets.get(config, name);
|
||||
return (true);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// absent or not allowed; keep looking
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Last scheduled deployment result, without network or database work.
|
||||
*******************************************************************************/
|
||||
@@ -130,6 +170,7 @@ public final class SlackReadiness
|
||||
List<SlackApiReadinessCheck> checks = new ArrayList<>();
|
||||
boolean good = false;
|
||||
boolean channels = true;
|
||||
boolean channelsUnknown = false;
|
||||
boolean optionalScopes = false;
|
||||
try
|
||||
{
|
||||
@@ -143,11 +184,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 +209,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 +218,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<String, String> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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 = "<!subteam^" + row.getValueString("slackUsergroupId") + "> ";
|
||||
String handle = row.getValueString("handle");
|
||||
mention = "<!subteam^" + row.getValueString("slackUsergroupId") + (handle != null && handle.matches("@?[A-Za-z0-9._-]+") ? "|@" + handle.replaceFirst("^@", "") : "") + "> ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
bundle: qbit-slack
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:slack;slot:help
|
||||
summary: "Connect your Slack account, manage message destinations and review delivery results."
|
||||
content: |
|
||||
Start with **{{label:slack_install}}** in **{{label:slack_connection}}**, then use **{{label:slack_connect}}** to link your own account. Register the channels and user groups you need, and review **{{label:slack_template}}** before sending.
|
||||
|
||||
Use **{{label:slack_message}}** to follow delivery progress and **{{label:slack_message_attempt}}** to investigate failures. Your home page shows your connections and recent deliveries. Administrators can also review workspace readiness, queued messages and recent failures.
|
||||
- key: table:slack_workspace;slot:help
|
||||
summary: "Slack workspaces installed for your organization, with connection health and readiness."
|
||||
content: |
|
||||
Use **{{label:slack_install}}** from **{{label:slack_connection}}** to create a workspace by approving access in Slack. You do not create workspace records by hand. Review **{{label:slack_check_readiness}}** results to identify missing access or channel setup. Administrators can use **{{label:slack_uninstall}}** to remove the installation.
|
||||
- key: table:slack_workspace;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_workspace;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_workspace;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_workspace;field:teamId
|
||||
summary: "The identifier Slack assigns to the workspace."
|
||||
- key: table:slack_workspace;field:teamName
|
||||
summary: "The workspace name returned by Slack."
|
||||
- key: table:slack_workspace;field:enterpriseId
|
||||
summary: "The Slack enterprise identifier associated with the installation."
|
||||
- key: table:slack_workspace;field:appId
|
||||
summary: "The identifier of the installed Slack app."
|
||||
- key: table:slack_workspace;field:tenantKey
|
||||
summary: "The organization that owns this configuration."
|
||||
- key: table:slack_workspace;field:installedByUserId
|
||||
summary: "The account that installed the Slack app."
|
||||
- key: table:slack_workspace;field:installedAt
|
||||
summary: "When the Slack app was installed."
|
||||
- key: table:slack_workspace;field:botUserId
|
||||
summary: "The Slack user identifier for the installed bot."
|
||||
- key: table:slack_workspace;field:botScopes
|
||||
summary: "The permissions granted to the Slack bot."
|
||||
- key: table:slack_workspace;field:botTokenFingerprint
|
||||
summary: "A credential identifier used to compare bot credentials without revealing them."
|
||||
- key: table:slack_workspace;field:status
|
||||
summary: "The current state of the Slack installation."
|
||||
- key: table:slack_workspace;field:readinessStatus
|
||||
summary: "The result of the latest workspace readiness check."
|
||||
- key: table:slack_workspace;field:readinessCheckedAt
|
||||
summary: "When workspace readiness was last checked."
|
||||
- key: table:slack_workspace;field:readinessSummary
|
||||
summary: "A short explanation of the latest workspace readiness result."
|
||||
- key: table:slack_workspace;field:health
|
||||
summary: "Whether recent delivery failures have made the workspace unhealthy."
|
||||
- key: table:slack_workspace;field:consecutiveFailures
|
||||
summary: "The number of consecutive delivery failures for the workspace."
|
||||
- key: table:slack_workspace;field:ratePerMinute
|
||||
summary: "The configured maximum number of message sends per minute for this workspace."
|
||||
- key: table:slack_workspace;field:botToken
|
||||
summary: "The protected credential used by the Slack bot."
|
||||
- key: table:slack_workspace;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_workspace;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_workspace;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_workspace;slot:help
|
||||
summary: "Available workspace records you can reference."
|
||||
- key: table:slack_connection;slot:help
|
||||
summary: "Links between your account and Slack, used to send messages with your Slack identity."
|
||||
content: |
|
||||
Use **{{label:slack_connect}}** and approve access in Slack to create your connection. Your organization must install the app first. Select your connection and use **{{label:slack_test_message}}** to check delivery. Use **{{label:slack_disconnect}}** when you want to revoke the link. Administrators can review connections for other users.
|
||||
- key: table:slack_connection;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_connection;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_connection;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_connection;field:userId
|
||||
summary: "The application account associated with this Slack activity."
|
||||
- key: table:slack_connection;field:workspaceId
|
||||
summary: "The Slack workspace associated with this record."
|
||||
- key: table:slack_connection;field:slackUserId
|
||||
summary: "The user identifier supplied by Slack."
|
||||
- key: table:slack_connection;field:slackUserName
|
||||
summary: "The connected user name returned by Slack."
|
||||
- key: table:slack_connection;field:userTokenFingerprint
|
||||
summary: "A credential identifier used to compare user credentials without revealing them."
|
||||
- key: table:slack_connection;field:userScopes
|
||||
summary: "The permissions granted by the connected Slack user."
|
||||
- key: table:slack_connection;field:status
|
||||
summary: "The current state of the personal Slack connection."
|
||||
- key: table:slack_connection;field:connectedAt
|
||||
summary: "When the Slack account was connected."
|
||||
- key: table:slack_connection;field:lastVerifiedAt
|
||||
summary: "When the connection was last verified."
|
||||
- key: table:slack_connection;field:revokedAt
|
||||
summary: "When access through this connection was revoked."
|
||||
- key: table:slack_connection;field:lastError
|
||||
summary: "The latest recorded error explanation."
|
||||
- key: table:slack_connection;field:userToken
|
||||
summary: "The protected credential for the connected Slack account."
|
||||
- key: table:slack_connection;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_connection;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_connection;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_connection;slot:help
|
||||
summary: "Available connection records you can reference."
|
||||
- key: table:slack_channel;slot:help
|
||||
summary: "Named Slack channels available as message destinations, with their latest access checks."
|
||||
content: |
|
||||
Add a registered channel by choosing a workspace, entering a short **Name** and supplying its **Slack channel id**. Invite the app to the channel in Slack, then run **{{label:slack_check_channels}}** on the selected registered channels. Review the retrieved name, membership and archive status before using the channel for delivery. Disable channels you no longer want senders to use.
|
||||
- key: table:slack_channel;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_channel;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_channel;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_channel;field:name
|
||||
summary: "The short name used to select this configuration."
|
||||
- key: table:slack_channel;field:label
|
||||
summary: "The display name for this configuration."
|
||||
- key: table:slack_channel;field:workspaceId
|
||||
summary: "The Slack workspace associated with this record."
|
||||
- key: table:slack_channel;field:slackChannelId
|
||||
summary: "The destination channel identifier supplied by Slack."
|
||||
- key: table:slack_channel;field:slackChannelName
|
||||
summary: "The channel name retrieved from Slack."
|
||||
- key: table:slack_channel;field:isPrivate
|
||||
summary: "Whether Slack reports this channel as private."
|
||||
- key: table:slack_channel;field:botIsMember
|
||||
summary: "Whether the installed bot belongs to this channel."
|
||||
- key: table:slack_channel;field:isArchived
|
||||
summary: "Whether Slack reports this channel as archived."
|
||||
- key: table:slack_channel;field:enabled
|
||||
summary: "Whether this configuration is available for use."
|
||||
- key: table:slack_channel;field:description
|
||||
summary: "The channel purpose retrieved from Slack."
|
||||
- key: table:slack_channel;field:checkedAt
|
||||
summary: "When the channel details were last checked against Slack."
|
||||
- key: table:slack_channel;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_channel;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_channel;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_channel;slot:help
|
||||
summary: "Available registered channel records you can reference."
|
||||
- key: table:slack_user_group;slot:help
|
||||
summary: "Named Slack user groups that messages can mention in a specified channel."
|
||||
content: |
|
||||
Add a group by choosing a workspace and entering a short **Name**, the **Slack usergroup id** and the destination **Channel id**. Messages to the group mention it in that channel. **{{label:slack_check_readiness}}** can retrieve the group handle when the installation has permission to read user groups.
|
||||
- key: table:slack_user_group;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_user_group;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_user_group;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_user_group;field:name
|
||||
summary: "The short name used to select this configuration."
|
||||
- key: table:slack_user_group;field:label
|
||||
summary: "The display name for this configuration."
|
||||
- key: table:slack_user_group;field:workspaceId
|
||||
summary: "The Slack workspace associated with this record."
|
||||
- key: table:slack_user_group;field:slackUsergroupId
|
||||
summary: "The identifier Slack assigns to the user group."
|
||||
- key: table:slack_user_group;field:handle
|
||||
summary: "The Slack handle used when mentioning this group."
|
||||
- key: table:slack_user_group;field:channelId
|
||||
summary: "The Slack channel where messages mention this group."
|
||||
- key: table:slack_user_group;field:enabled
|
||||
summary: "Whether this configuration is available for use."
|
||||
- key: table:slack_user_group;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_user_group;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_user_group;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_user_group;slot:help
|
||||
summary: "Available user group records you can reference."
|
||||
- key: table:slack_template;slot:help
|
||||
summary: "Reusable Slack message text and layouts with declared input values."
|
||||
content: |
|
||||
Review the built-in notification, digest and delivery templates, or add your own. Give each template a unique **Name** that senders can select. Declare the values it accepts in **Variables schema** and use them in **Text** or **Blocks template**. Existing templates are preserved when the built-ins are added again. Do not include personal or health information.
|
||||
- key: table:slack_template;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_template;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_template;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_template;field:name
|
||||
summary: "The short name used to select this configuration."
|
||||
- key: table:slack_template;field:label
|
||||
summary: "The display name for this configuration."
|
||||
- key: table:slack_template;field:description
|
||||
summary: "An explanation of the intended use of this template."
|
||||
- key: table:slack_template;field:category
|
||||
summary: "The category assigned to this message or template."
|
||||
- key: table:slack_template;field:enabled
|
||||
summary: "Whether this configuration is available for use."
|
||||
- key: table:slack_template;field:tenantKey
|
||||
summary: "The organization that owns this configuration."
|
||||
- key: table:slack_template;field:text
|
||||
summary: "The message text sent to Slack."
|
||||
- key: table:slack_template;field:blocksTemplate
|
||||
summary: "The reusable Slack message layout, with placeholders for declared values."
|
||||
- key: table:slack_template;field:variablesSchema
|
||||
summary: "The definitions and restrictions for values accepted by this template."
|
||||
content: |
|
||||
Declare every value your template accepts before using it in the message text or layout. These definitions control which inputs are valid and how they may be used. Keep personal and health information out of template values, and supply links as URLs rather than arbitrary text.
|
||||
- key: table:slack_template;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_template;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_template;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_template;slot:help
|
||||
summary: "Available template records you can reference."
|
||||
- key: table:slack_message;slot:help
|
||||
summary: "Queued Slack messages and their delivery history, including retries and failures."
|
||||
content: |
|
||||
Open a message to review its destination, sending identity and delivery status. Messages are created automatically when a process, alert, delivery or test requests a send. You do not add or edit this history by hand. Review **{{label:slack_message_attempt}}** for delivery errors.
|
||||
- key: table:slack_message;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_message;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_message;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_message;field:ownerUserId
|
||||
summary: "The application account responsible for sending this message."
|
||||
- key: table:slack_message;field:workspaceId
|
||||
summary: "The Slack workspace associated with this record."
|
||||
- key: table:slack_message;field:connectionId
|
||||
summary: "The personal Slack connection used for this message."
|
||||
- key: table:slack_message;field:postAs
|
||||
summary: "Whether this message is sent as the bot or a connected user."
|
||||
- key: table:slack_message;field:operation
|
||||
summary: "Whether the request posts a message, updates one or uploads a file."
|
||||
- key: table:slack_message;field:targetType
|
||||
summary: "The kind of recipient or destination for this message."
|
||||
- key: table:slack_message;field:targetRef
|
||||
summary: "The requested recipient or destination reference."
|
||||
- key: table:slack_message;field:slackChannelId
|
||||
summary: "The destination channel identifier supplied by Slack."
|
||||
- key: table:slack_message;field:channelLabel
|
||||
summary: "The display name recorded for the destination channel."
|
||||
- key: table:slack_message;field:status
|
||||
summary: "The current delivery state, from queuing through completion or failure."
|
||||
- key: table:slack_message;field:category
|
||||
summary: "The category assigned to this message or template."
|
||||
- key: table:slack_message;field:templateName
|
||||
summary: "The name of the template used to prepare the message."
|
||||
- key: table:slack_message;field:text
|
||||
summary: "The message text sent to Slack."
|
||||
- key: table:slack_message;field:blocksJson
|
||||
summary: "The prepared Slack message layout sent with this request."
|
||||
- key: table:slack_message;field:variablesSnapshot
|
||||
summary: "The template values captured when the message was prepared."
|
||||
- key: table:slack_message;field:idempotencyKey
|
||||
summary: "A unique request reference that prevents the same send from being queued twice."
|
||||
content: |
|
||||
Use this reference to identify repeated requests for the same send. The message queue requires it to be unique, so retrying a request with the same reference does not create another queued message. This prevents duplicate requests; review the delivery status separately to confirm whether Slack received the message.
|
||||
- key: table:slack_message;field:parentMessageId
|
||||
summary: "The earlier message this reply waits for before it can be delivered."
|
||||
- key: table:slack_message;field:threadTs
|
||||
summary: "The Slack identifier of the conversation thread."
|
||||
- key: table:slack_message;field:targetMessageId
|
||||
summary: "The existing message that this request updates."
|
||||
- key: table:slack_message;field:attachmentStorageRef
|
||||
summary: "The stored file reference used to retrieve the attachment."
|
||||
- key: table:slack_message;field:attachmentStorageTable
|
||||
summary: "The storage collection holding the attachment."
|
||||
- key: table:slack_message;field:attachmentName
|
||||
summary: "The name of the attached file."
|
||||
- key: table:slack_message;field:attachmentBytes
|
||||
summary: "The size of the attachment in bytes."
|
||||
- key: table:slack_message;field:slackFileId
|
||||
summary: "The identifier Slack returned for the uploaded file."
|
||||
- key: table:slack_message;field:fileReconcileRequired
|
||||
summary: "Whether an uncertain file upload needs reconciliation before another attempt."
|
||||
content: |
|
||||
A file request may reach Slack even when its response is lost. This flag indicates that the sender must check what happened before trying the upload again. Review the file identifier and attempt history when investigating the message, so an uncertain result is not mistaken for a confirmed failure.
|
||||
- key: table:slack_message;field:slackTs
|
||||
summary: "The identifier Slack returned for the posted message."
|
||||
- key: table:slack_message;field:permalink
|
||||
summary: "A direct link to the message in Slack."
|
||||
- key: table:slack_message;field:attemptCount
|
||||
summary: "The number of delivery attempts recorded for this message."
|
||||
- key: table:slack_message;field:nextAttemptDate
|
||||
summary: "When this message next becomes eligible for a delivery attempt."
|
||||
- key: table:slack_message;field:leaseExpiresDate
|
||||
summary: "When the current delivery worker loses its exclusive claim on the message."
|
||||
- key: table:slack_message;field:lastErrorCode
|
||||
summary: "The code for the latest delivery error."
|
||||
- key: table:slack_message;field:lastError
|
||||
summary: "The latest recorded error explanation."
|
||||
- key: table:slack_message;field:sentDate
|
||||
summary: "When delivery was recorded as successful."
|
||||
- key: table:slack_message;field:deadLetterDate
|
||||
summary: "When automatic delivery attempts stopped for this message."
|
||||
- key: table:slack_message;field:linkedTableName
|
||||
summary: "The source collection associated with this message."
|
||||
- key: table:slack_message;field:linkedRecordId
|
||||
summary: "The source record associated with this message."
|
||||
- key: table:slack_message;field:senderName
|
||||
summary: "The sender name recorded with this message."
|
||||
- key: table:slack_message;field:deliveryLogId
|
||||
summary: "The related delivery log reference."
|
||||
- key: table:slack_message;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_message;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_message;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_message;slot:help
|
||||
summary: "Available message records you can reference."
|
||||
- key: table:slack_message_attempt;slot:help
|
||||
summary: "Individual attempts to deliver a Slack message, with outcomes, errors and timing."
|
||||
content: |
|
||||
Use these records to investigate a message that is waiting to retry or has failed. Each delivery attempt records its outcome, timing and any error returned. Follow **Message id** to the related message for its destination and overall status. Attempt records are created automatically and cannot be edited.
|
||||
- key: table:slack_message_attempt;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_message_attempt;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_message_attempt;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_message_attempt;field:messageId
|
||||
summary: "The message associated with this record."
|
||||
- key: table:slack_message_attempt;field:attemptNumber
|
||||
summary: "The sequence number of this delivery attempt."
|
||||
- key: table:slack_message_attempt;field:outcome
|
||||
summary: "The result of this individual delivery attempt."
|
||||
- key: table:slack_message_attempt;field:errorCode
|
||||
summary: "The error code recorded for this delivery attempt."
|
||||
- key: table:slack_message_attempt;field:httpStatus
|
||||
summary: "The response status returned by the Slack request."
|
||||
- key: table:slack_message_attempt;field:retryAfterSeconds
|
||||
summary: "The number of seconds Slack asked the sender to wait before retrying."
|
||||
- key: table:slack_message_attempt;field:postedAs
|
||||
summary: "Whether the attempt used the bot or a connected user."
|
||||
- key: table:slack_message_attempt;field:claimedBy
|
||||
summary: "The delivery worker that claimed this attempt."
|
||||
- key: table:slack_message_attempt;field:startDate
|
||||
summary: "When this delivery attempt started."
|
||||
- key: table:slack_message_attempt;field:endDate
|
||||
summary: "When this delivery attempt ended."
|
||||
- key: table:slack_message_attempt;field:error
|
||||
summary: "The recorded explanation of the failure."
|
||||
- key: table:slack_message_attempt;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_message_attempt;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_message_attempt;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_message_attempt;slot:help
|
||||
summary: "Available message attempt records you can reference."
|
||||
- key: table:slack_workspace;section:connections
|
||||
summary: "Personal Slack connections for this workspace."
|
||||
- key: table:slack_workspace;section:channels
|
||||
summary: "Registered channels belonging to this workspace."
|
||||
- key: table:slack_workspace;section:groups
|
||||
summary: "User groups registered for this workspace."
|
||||
- key: table:slack_message;section:attempts
|
||||
summary: "Delivery attempts recorded for this message."
|
||||
- key: possibleValueSource:slack_workspace_status;slot:help
|
||||
summary: "The available states of a Slack installation."
|
||||
- key: possibleValueSource:slack_connection_status;slot:help
|
||||
summary: "The available states of a personal Slack connection."
|
||||
- key: possibleValueSource:slack_readiness_status;slot:help
|
||||
summary: "The possible results of a workspace readiness check."
|
||||
- key: possibleValueSource:slack_health_status;slot:help
|
||||
summary: "The available workspace delivery health states."
|
||||
- key: possibleValueSource:slack_message_status;slot:help
|
||||
summary: "The available message delivery states."
|
||||
- key: possibleValueSource:slack_message_operation;slot:help
|
||||
summary: "The supported message operations: post, update and file upload."
|
||||
- key: possibleValueSource:slack_target_type;slot:help
|
||||
summary: "The supported Slack recipient and destination types."
|
||||
- key: possibleValueSource:slack_post_as;slot:help
|
||||
summary: "The available sending identities: bot or connected user."
|
||||
- key: possibleValueSource:slack_attempt_outcome;slot:help
|
||||
summary: "The possible results of a single delivery attempt."
|
||||
- key: widget:slackHome;slot:label
|
||||
summary: "Your Slack connections and their current status."
|
||||
- key: widget:slackHomeDeliveries;slot:label
|
||||
summary: "Your Slack delivery totals for the last 24 hours."
|
||||
- key: widget:slackHomeRecent;slot:label
|
||||
summary: "Your most recent Slack deliveries."
|
||||
- key: widget:slackHomeReadiness;slot:label
|
||||
summary: "Workspace readiness results available to administrators."
|
||||
- key: widget:slackHomeOutbox;slot:label
|
||||
summary: "Totals for queued Slack messages and other delivery states."
|
||||
- key: widget:slackHomeFailures;slot:label
|
||||
summary: "Recent Slack delivery failures available to administrators."
|
||||
- key: widget:slack_connection_children;slot:label
|
||||
summary: "Personal Slack connections for this workspace."
|
||||
- key: widget:slack_channel_children;slot:label
|
||||
summary: "Channels registered for this workspace."
|
||||
- key: widget:slack_user_group_children;slot:label
|
||||
summary: "User groups registered for this workspace."
|
||||
- key: widget:slack_message_children;slot:label
|
||||
summary: "Messages associated with this workspace."
|
||||
- key: widget:slack_message_attempt_children;slot:label
|
||||
summary: "Delivery attempts for this message."
|
||||
- key: process:slack_connect;slot:help
|
||||
summary: "Link your account to Slack so you can send messages with your Slack identity."
|
||||
content: |
|
||||
Use **{{label:slack_connect}}** in **{{label:slack_connection}}** to obtain a Slack authorization link. Open the link and approve the requested access in Slack. Your workspace must already have the app installed. After approval, review your connection and use **{{label:slack_test_message}}** to check it.
|
||||
- key: process:slack_install;slot:help
|
||||
summary: "Install the Slack app for your organization through Slack authorization."
|
||||
content: |
|
||||
Use **{{label:slack_install}}** in **{{label:slack_connection}}** to obtain the installation link. Open it in Slack and approve access for the intended workspace. Approval creates the workspace record and stores the protected bot credential. You need permission to install the app; individual users then connect their own accounts.
|
||||
- key: process:slack_test_message;slot:help
|
||||
summary: "Queue a test message to your own Slack account using the selected connection."
|
||||
content: |
|
||||
Select one of your own records in **{{label:slack_connection}}**, then choose **{{label:slack_test_message}}**. Review the selection before continuing. You need permission to send messages. The action queues a test for delivery; check **{{label:slack_message}}** and its attempts if the test does not arrive in Slack.
|
||||
- key: process:slack_disconnect;slot:help
|
||||
summary: "Revoke the selected personal Slack connections."
|
||||
content: |
|
||||
Select the connections you want to revoke and choose **{{label:slack_disconnect}}**. Review the selected records before continuing. You can disconnect your own account with connection permission; administrators can manage other connections they can access. Use **{{label:slack_connect}}** again if you later need to restore your personal connection.
|
||||
- key: process:slack_uninstall;slot:help
|
||||
summary: "Remove the Slack app installation for the selected workspaces."
|
||||
content: |
|
||||
As an administrator, select the workspace installations you want to remove and choose **{{label:slack_uninstall}}**. Review the selected records before continuing. This action removes the workspace installation, rather than just disconnecting your own account. Use **{{label:slack_disconnect}}** in **{{label:slack_connection}}** when you only want to revoke a personal connection.
|
||||
- key: process:slack_check_readiness;slot:help
|
||||
summary: "Check workspace setup, permissions and registered Slack destinations."
|
||||
content: |
|
||||
Select a workspace and choose **{{label:slack_check_readiness}}** to refresh the readiness information. Review the selection before continuing. You need administrator access to run this check. Inspect the resulting status and summary, and review registered channels when access or membership needs attention before relying on message delivery.
|
||||
- key: process:slack_readiness;slot:help
|
||||
summary: "Refresh Slack readiness and add any missing built-in templates."
|
||||
content: |
|
||||
This scheduled action refreshes deployment and workspace readiness and adds missing built-in templates. Your edits to existing templates are preserved. Review **Workspace readiness** on the Slack home page to see issues that need attention. Administrators can also use **{{label:slack_check_readiness}}** from **{{label:slack_workspace}}** when they need a fresh check.
|
||||
- key: process:slack_dispatch;slot:help
|
||||
summary: "Process Slack messages that are due for delivery and record the results."
|
||||
content: |
|
||||
This scheduled action picks up messages that are due for delivery and attempts to send them to Slack. Review **{{label:slack_message}}** for the current delivery state and **{{label:slack_message_attempt}}** for individual results and errors. A queued request is not proof of delivery; check its recorded result when investigating a missing message.
|
||||
- key: table:slack_interaction;slot:help
|
||||
summary: "Recorded Slack button interactions, with their processing results."
|
||||
content: |
|
||||
Review **Interactions** on a message to see who acted and whether the action completed. These records are created automatically when Slack sends button interactions. You do not add or edit this audit history. Button actions run with the clicking user's permissions.
|
||||
- key: process:slack_check_channels;slot:help
|
||||
summary: "Refresh the Slack details and access status of selected registered channels."
|
||||
content: |
|
||||
Select records in **{{label:slack_channel}}** and choose **{{label:slack_check_channels}}**. As an administrator, review the selection before continuing. The check updates the channel name, privacy, membership, archive status, purpose and check time. Review warnings for channels that need attention and retry later if Slack could not confirm their status.
|
||||
- key: possibleValueSource:slack_interaction;slot:help
|
||||
summary: "Recorded Slack interactions you can reference."
|
||||
- key: possibleValueSource:slack_interaction_outcome;slot:help
|
||||
summary: "The possible processing results for a Slack interaction."
|
||||
- key: widget:slack_interaction_children;slot:label
|
||||
summary: "Slack interactions recorded for this message."
|
||||
- key: table:slack_message;section:interactions
|
||||
summary: "Button clicks and their processing results for this message."
|
||||
- key: table:slack_interaction;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_interaction;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_interaction;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_interaction;field:workspaceId
|
||||
summary: "The Slack workspace associated with this interaction."
|
||||
- key: table:slack_interaction;field:messageId
|
||||
summary: "The message associated with this interaction."
|
||||
- key: table:slack_interaction;field:teamId
|
||||
summary: "The workspace identifier supplied by Slack."
|
||||
- key: table:slack_interaction;field:slackUserId
|
||||
summary: "The Slack user who performed the action."
|
||||
- key: table:slack_interaction;field:userId
|
||||
summary: "The application account associated with this action."
|
||||
- key: table:slack_interaction;field:actionName
|
||||
summary: "The action requested by the Slack interaction."
|
||||
- key: table:slack_interaction;field:deliveryKey
|
||||
summary: "The unique delivery reference used to recognize repeated Slack requests."
|
||||
- key: table:slack_interaction;field:error
|
||||
summary: "The recorded explanation of a processing failure."
|
||||
- key: table:slack_interaction;field:outcome
|
||||
summary: "The processing result of this interaction."
|
||||
- key: table:slack_interaction;field:completedDate
|
||||
summary: "When processing of this interaction completed."
|
||||
- key: table:slack_interaction;section:identity
|
||||
summary: "The interaction identity, related records and processing result."
|
||||
- key: table:slack_interaction;section:data
|
||||
summary: "Additional action details and processing information."
|
||||
- key: table:slack_interaction;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: process:slack_workspace.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_workspace}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_workspace.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_workspace.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_workspace.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_workspace}}**. Review the selection before continuing and check the result for any records that could not be deleted. To revoke access in Slack, use **{{label:slack_uninstall}}** before deleting the record.
|
||||
- key: process:slack_connection.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_connection}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_connection.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_connection.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_connection.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_connection}}**. Review the selection before continuing and check the result for any records that could not be deleted. To revoke access in Slack, use **{{label:slack_disconnect}}** before deleting the record.
|
||||
- key: process:slack_channel.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_channel}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_channel.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_channel.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_channel.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_channel}}**. Review the selection before continuing and check the result for any records that could not be deleted.
|
||||
- key: process:slack_channel.bulkInsert;slot:help
|
||||
summary: "Add records from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_channel.bulkInsert}}** to add records to **{{label:slack_channel}}** from a file. Map the columns to the required fields, review the proposed records and correct validation errors before continuing. Check the results after the import.
|
||||
- key: process:slack_user_group.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_user_group}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_user_group.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_user_group.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_user_group.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_user_group}}**. Review the selection before continuing and check the result for any records that could not be deleted.
|
||||
- key: process:slack_user_group.bulkInsert;slot:help
|
||||
summary: "Add records from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_user_group.bulkInsert}}** to add records to **{{label:slack_user_group}}** from a file. Map the columns to the required fields, review the proposed records and correct validation errors before continuing. Check the results after the import.
|
||||
- key: process:slack_template.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_template}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_template.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_template.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_template.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_template}}**. Review the selection before continuing and check the result for any records that could not be deleted.
|
||||
- key: process:slack_template.bulkInsert;slot:help
|
||||
summary: "Add records from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_template.bulkInsert}}** to add records to **{{label:slack_template}}** from a file. Map the columns to the required fields, review the proposed records and correct validation errors before continuing. Check the results after the import.
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -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,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("<!subteam^S123|@oncall-team> ");
|
||||
|
||||
env.set(SlackTables.USER_GROUP, group, "handle", "bad|handle>");
|
||||
assertThat(sentText(env)).as("an unsafe handle is ignored").startsWith("<!subteam^S123> ");
|
||||
env.set(SlackTables.USER_GROUP, group, "handle", null);
|
||||
assertThat(sentText(env)).startsWith("<!subteam^S123> ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<FakeSlackServer.Reply> 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"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,8 @@ class SlackQBitProducerTest
|
||||
SlackQBitProducer producer = new SlackQBitProducer().withQBitConfig(config).withNamespace("ns");
|
||||
|
||||
QBitMetaData metaData = producer.getQBitMetaData();
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
|
||||
assertThat(metaData.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(SlackQBitProducer.VERSION);
|
||||
assertThat(metaData.getArtifactId()).isEqualTo("qbit-slack");
|
||||
assertThat(metaData.getGroupId()).isEqualTo("com.kingsrook.qbits");
|
||||
assertThat(producer.getNamespace()).isEqualTo("ns");
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
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.model.metadata.QInstance;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -62,6 +63,58 @@ class SlackReadinessTest
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No Slack secrets at all is "not configured": UNKNOWN, never DOWN, so an
|
||||
** optional, unset Slack integration can't fail a readiness probe.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notConfiguredIsUnknownNotDown() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
env.config().withSecretResolver(n -> null);
|
||||
assertThat(SlackReadiness.deployment(env.instance()).checks())
|
||||
.anyMatch(c -> c.name().equals("app") && c.status().equals(SlackReadiness.NOT_CONFIGURED));
|
||||
var result = new SlackHealth().check(env.instance());
|
||||
assertThat(result.getStatus().name()).isEqualTo("UNKNOWN");
|
||||
assertThat(result.getDetails()).containsEntry("configured", false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Before readiness has run, health is UNKNOWN rather than degraded or down.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notYetCheckedIsUnknown() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
QInstance fresh = new QInstance();
|
||||
assertThat(new SlackHealth().check(fresh).getStatus().name()).isEqualTo("UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Partially configured (some secrets set, others missing) is a real
|
||||
** misconfiguration and stays DOWN.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void partiallyConfiguredIsDown() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
env.config().withSecretResolver(n -> "SLACK_CLIENT_ID".equals(n) ? "123.456" : null);
|
||||
SlackReadiness.deployment(env.instance());
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DOWN");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A valid Base64 string still needs exactly 32 decoded bytes.
|
||||
*******************************************************************************/
|
||||
@@ -166,7 +219,7 @@ class SlackReadinessTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Uninitialized readiness maps to degraded health without network access.
|
||||
** Uninitialized readiness maps to UNKNOWN health without network access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notReadyHealth() throws Exception
|
||||
@@ -174,7 +227,7 @@ class SlackReadinessTest
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
assertThat(SlackReadiness.stored(env.instance()).ready()).isFalse();
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DEGRADED");
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("UNKNOWN");
|
||||
assertThat(env.slack().requests()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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.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.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
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<String, String> 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<String, String> 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<String, String> constantByTable = constantByTable();
|
||||
assertThat(constantByTable.keySet()).containsAll(SlackTables.ALL);
|
||||
List<String> runtimeSource = new ArrayList<>();
|
||||
try(Stream<Path> 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<QRecord> 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 ->
|
||||
{
|
||||
});
|
||||
HelpBundleAssertions.forInstance(instance)
|
||||
.bundles("help/qbit-slack.yaml")
|
||||
.owning(target -> target.name().startsWith("slack"))
|
||||
.assertComplete();
|
||||
var bundles = HelpBundles.of(instance).getBundles();
|
||||
assertThat(bundles).hasSize(1);
|
||||
assertThat(bundles.get(0).getId()).isEqualTo("qbit-slack");
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
|
||||
assertThat(bundles.get(0).getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(SlackQBitProducer.VERSION);
|
||||
for(String table : SlackTables.ALL)
|
||||
{
|
||||
assertThat(bundles.get(0).getEntries()).filteredOn(entry -> entry.getKey().equals("table:" + table + ";slot:help"))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.getContent()).isNotBlank());
|
||||
}
|
||||
// Hosts without the help table still register metadata; core safely skips sync.
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SlackTables declares CONSTANT = "slack_x"; table name to constant name.
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> constantByTable()
|
||||
{
|
||||
Map<String, String> 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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user