Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81297d6e5 | ||
|
|
0254d1b599
|
||
|
|
c22f8ba125 | ||
|
|
cdbfef9b4c
|
||
|
|
92d42b3b07 |
No files matched your search
+61
-10
@@ -1,14 +1,65 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
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
|
||||
|
||||
workflows:
|
||||
setup:
|
||||
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
- 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+$/ ]
|
||||
@@ -1,7 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -1,23 +0,0 @@
|
||||
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@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,74 +1,25 @@
|
||||
# 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: Plan a cut without changing release state
|
||||
description: Print the plan without merging or creating branches
|
||||
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
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
release-rc:
|
||||
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@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
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
|
||||
with:
|
||||
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
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
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
|
||||
+1
-11
@@ -6,18 +6,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- `SlackDeliveryDestination` now implements the QQQ 4.1 core delivery SPI (`com.kingsrook.qqq.backend.core.delivery`, qqq#1205) and the producer registers it as `slack` in the core `DeliveryDestinationsMetaData` registry; the stand-in SPI copy is removed.
|
||||
- `SlackPayloadGuard` uses core `ClassifiedValues` for field classification and `ClassifiedValueTokens.changed()` for tokenized restricted values (qqq#1142), replacing its stand-in logic.
|
||||
|
||||
### Fixed
|
||||
- **Install for my organization** and **Connect Slack** on the Connections table ended on an empty screen with only a Return button: the screen declared a view field but no component, and a frontend step shows only its components. The screen is now an HTML component with one sentence about what happens, a **Continue to Slack** link to the consent page (same tab) and the link's lifetime (10 minutes, `SlackOAuthState.LIFETIME_SECONDS`).
|
||||
- After Slack, the callback redirected to `/preferences?section=slack`, which the host UI does not serve. It now returns to `/app/preferences?section=slack` with `&result=connected` or `&error=<code>`.
|
||||
- Connecting in an environment without the public URL or the Slack app secrets failed with `IllegalArgumentException (Slack public URL is required)` or `Slack secret unavailable`. It is now refused with a user-facing message: "Slack is not set up in this environment", which of the two is missing, and the names of the missing environment variables, never a value.
|
||||
- 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.
|
||||
- Help bundle `help/qbit-slack.yaml` (stand-in for qqq#1198): every table and the app say what they are for and how rows get created, so an empty table explains itself.
|
||||
- "Check channels" action on Registered Channels; readiness now writes the channel's Slack name, private, bot is member, archived, purpose and checked time back to the row.
|
||||
- User group `handle` is resolved from `usergroups.list` during readiness (optional `usergroups:read` scope) and used in the mention (`<!subteam^ID|@handle>`).
|
||||
- `SlackTablesGuardrailTest`: every table is used at runtime, seeded or listed as starting empty, and has help.
|
||||
|
||||
@@ -12,7 +12,7 @@ Design: [docs/DESIGN.md](docs/DESIGN.md).
|
||||
## Requirements
|
||||
|
||||
- Java 21
|
||||
- QQQ 4.1 (currently built against 4.1.0-RC.7)
|
||||
- QQQ 4.1 (currently built against 4.1.0-RC.6)
|
||||
- A Slack app created from [docs/slack-app-manifest.yaml](docs/slack-app-manifest.yaml)
|
||||
|
||||
## Install
|
||||
@@ -55,19 +55,6 @@ Send with core `SendMessageAction` and the provider name `slack`, or deliver out
|
||||
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
-34
@@ -121,12 +121,7 @@ There is one row per QQQ user and workspace row, unique (`userId`, `workspaceId`
|
||||
|
||||
1. **Start** (UI API only). It's a preference-section action, **Connect Slack**, in the user's Slack
|
||||
section (core #1162), or the same action on the Connections table.
|
||||
- It returns the Slack authorize URL. Nobody copies it: the preference action answers with the address
|
||||
alone, which the UI opens (see Preferences below), and the Connections table action ends on a screen
|
||||
with one sentence, a **Continue to Slack** link and the link's lifetime.
|
||||
- An environment without the public URL or without the Slack app secrets is refused before any state is
|
||||
issued: "Slack is not set up in this environment", which of the two is missing, and the names of the
|
||||
missing environment variables (never a value).
|
||||
- It returns the Slack authorize URL.
|
||||
- The `state` is a server-signed, single-use token over the user id, tenant key, nonce, mode
|
||||
(`user` or `user+bot`) and a 10-minute expiry.
|
||||
- Users who hold `slack.workspace.install` in their tenant may also request the bot scopes.
|
||||
@@ -137,11 +132,8 @@ There is one row per QQQ user and workspace row, unique (`userId`, `workspaceId`
|
||||
environment variables.
|
||||
- It upserts the tenant's `slack_workspace` (bot token, when granted) and the user's `slack_connection`
|
||||
(user token and Slack user id).
|
||||
- Then it redirects to the Slack section of the Preferences page,
|
||||
`/app/preferences?section=slack&result=connected`, which says the connection succeeded.
|
||||
- Errors redirect there with a fixed code, `&error=<code>` (`sign_in_required`, `not_permitted`,
|
||||
`authorization_denied`, `exchange_failed`, `connect_failed`), never provider or token text. The page
|
||||
shows the code as words.
|
||||
- Then it redirects to the Preferences page with a result notice.
|
||||
- Errors redirect with a plain message, never token text.
|
||||
3. **Disconnect** calls `auth.revoke` on the user token, then marks the row REVOKED and clears the token.
|
||||
**Uninstall** (admin) calls `apps.uninstall` or revokes the bot token, then marks the workspace
|
||||
UNINSTALLED.
|
||||
@@ -238,15 +230,14 @@ 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`.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.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.
|
||||
- **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)`.
|
||||
|
||||
### 4.3 Public API `com.kingsrook.qbits.slack.api` (mirrors `EmailApi`)
|
||||
|
||||
@@ -438,11 +429,6 @@ 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.
|
||||
@@ -543,16 +529,7 @@ 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**;
|
||||
- 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 as the whole of
|
||||
`UserPreferenceActionResult.message`: core has no external-link or redirect result field (checked through
|
||||
QQQ 4.1.0-RC.31). qqq-frontend-next sends the browser to a message that is only an `https:` address
|
||||
instead of showing it, so the message must stay the address and nothing else. A typed redirect field in
|
||||
core would replace this convention.
|
||||
- for tenant admins, **Install for my organization**.
|
||||
|
||||
Without qbit-alerts, callers use `SendMessageAction` (`slack`, `me` or `user:<id>`) directly.
|
||||
|
||||
@@ -717,7 +694,7 @@ DDL is `docs/ddl/slack-postgres.sql`. kof22 applies it as a Liquibase changeset.
|
||||
|
||||
**Phase 1f: app, home and preferences section.**
|
||||
|
||||
- Built directly on the core #1162 preferences SPI (RC.7), with a class-availability guard for registration.
|
||||
- Built on core #1162 (RC.7). Until then the work is behind a stand-in.
|
||||
- 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,8 +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>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<revision>0.1.0-RC.3</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -35,7 +34,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.2.1</version>
|
||||
<version>2.1.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -78,7 +77,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<version>1.0.0-RC.19</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -121,35 +120,7 @@
|
||||
</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>
|
||||
@@ -242,7 +213,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -21,11 +21,8 @@
|
||||
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;
|
||||
@@ -34,7 +31,6 @@ 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;
|
||||
|
||||
@@ -46,41 +42,13 @@ 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 = loadVersion();
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
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.
|
||||
*******************************************************************************/
|
||||
@@ -179,13 +147,10 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
|
||||
output.addSelfToInstance(qInstance);
|
||||
com.kingsrook.qbits.slack.delivery.SlackDeliveryDestination.register(qInstance, getQBitConfig());
|
||||
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);
|
||||
if(isPreferencesAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.slack.preferences.SlackPreferencesProvider.register(qInstance);
|
||||
}
|
||||
com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter.register(qInstance);
|
||||
com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance);
|
||||
com.kingsrook.qbits.slack.interactivity.SlackActions.registerBuiltIns(qInstance, getQBitConfig());
|
||||
try
|
||||
@@ -197,31 +162,12 @@ 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");
|
||||
com.kingsrook.qbits.slack.metadata.SlackHelp.apply(qInstance);
|
||||
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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -21,23 +21,21 @@
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -45,15 +43,22 @@ import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterfa
|
||||
** producer under the config's bridgeProviderName), so any process sends with
|
||||
** core SendMessageAction and depends only on core (docs/DESIGN.md 4.2).
|
||||
**
|
||||
** 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.
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
public class SlackOutboxMessagingProvider implements MessagingProviderInterface
|
||||
{
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(SlackOutboxMessagingProvider.class);
|
||||
|
||||
|
||||
|
||||
@@ -69,75 +74,49 @@ public class SlackOutboxMessagingProvider implements MessagingProviderInterface
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
** Queue one outbox row per recipient. Anything refused is reported after the
|
||||
** others were queued, as one QUserFacingException.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SendMessageOutput sendMessage(SendMessageInput input) throws QException
|
||||
{
|
||||
if(input.getAttachmentList() != null && !input.getAttachmentList().isEmpty())
|
||||
{
|
||||
return (refused("Slack attachments aren't supported yet"));
|
||||
throw (new QUserFacingException("Slack attachments aren't supported yet"));
|
||||
}
|
||||
|
||||
List<String> addresses = new ArrayList<>();
|
||||
collect(input.getTo(), addresses);
|
||||
addresses = addresses.stream().distinct().toList();
|
||||
if(addresses.isEmpty())
|
||||
{
|
||||
return (refused("A Slack recipient is required"));
|
||||
throw (new QUserFacingException("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)
|
||||
{
|
||||
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()));
|
||||
SlackSendResult result = SlackService.enqueue(new SlackSendRequest().withTo(address).withText(text));
|
||||
if(result.status() == SlackSendResult.Status.REFUSED)
|
||||
{
|
||||
refusals.addAll(result.reasons());
|
||||
}
|
||||
else
|
||||
{
|
||||
ids.add(result.messageId());
|
||||
queued |= result.status() == SlackSendResult.Status.QUEUED;
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// 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()));
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refusal before any outbox row is created.
|
||||
*******************************************************************************/
|
||||
private static SendMessageOutput refused(String reason)
|
||||
{
|
||||
return (new SendMessageOutput().withStatus(SendMessageStatus.REFUSED).withReasons(List.of(reason)));
|
||||
if(!refusals.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException(String.join("; ", refusals)));
|
||||
}
|
||||
return (new SendMessageOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,9 +26,15 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
import com.kingsrook.qbits.slack.api.SlackApi;
|
||||
import com.kingsrook.qbits.slack.api.SlackApiMessage;
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryDestinationContext; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryDestinationInterface; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryRequest; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryResult; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryTargetOption; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryTargetStep; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.QDeliveryDestinationMetaData; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendRequest;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendResult;
|
||||
@@ -39,72 +45,45 @@ import com.kingsrook.qbits.slack.support.SlackRuntime;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryDestinationContext;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryDestinationInterface;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryDestinationsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryRequest;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryResult;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryTargetOption;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryTargetStep;
|
||||
import com.kingsrook.qqq.backend.core.delivery.QDeliveryDestinationMetaData;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
/*******************************************************************************
|
||||
** Slack output destination for the QQQ core delivery SPI
|
||||
** (DeliveryDestinationInterface). The producer registers it in the core
|
||||
** DeliveryDestinationsMetaData registry, so hosts that install core delivery
|
||||
** offer it in their pickers. Targets are workspaceId and where (me or a
|
||||
** channel address from SlackApi).
|
||||
** Host-callable Slack output destination until core's delivery SPI is released.
|
||||
** Targets are workspaceId and where (me or a channel address from SlackApi).
|
||||
*******************************************************************************/
|
||||
public class SlackDeliveryDestination implements DeliveryDestinationInterface
|
||||
{
|
||||
public static final String NAME = "slack";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the policy core applies before invoking this destination, using
|
||||
** the Slack config of the current instance.
|
||||
** Describe the policy hosts must apply before invoking this destination.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData metadata()
|
||||
{
|
||||
return (metadata(SlackRuntime.config()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the policy core applies before invoking this destination.
|
||||
*******************************************************************************/
|
||||
public static QDeliveryDestinationMetaData metadata(SlackQBitConfig config)
|
||||
{
|
||||
return (new QDeliveryDestinationMetaData().withName(NAME).withLabel("Slack")
|
||||
return (new QDeliveryDestinationMetaData().withName("slack").withLabel("Slack")
|
||||
.withPermissionName("slack.send").withCodeReference(new QCodeReference(SlackDeliveryDestination.class))
|
||||
.withMaxClassification(DataClassification.CONFIDENTIAL)
|
||||
.withAboveMaxClassification(QDeliveryDestinationMetaData.AboveMaxClassification.REFUSE)
|
||||
.withMaxAttachmentBytes(config.getMaxAttachmentBytes()).withAllowBearerLinks(false));
|
||||
.withMaxAttachmentBytes(SlackRuntime.config().getMaxAttachmentBytes()).withAllowBearerLinks(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register with the core delivery registry; production may run repeatedly,
|
||||
** and a destination already registered under this name is kept.
|
||||
** Detect the real SPI without registering an incompatible temporary instance.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance, SlackQBitConfig config)
|
||||
public static void register()
|
||||
{
|
||||
DeliveryDestinationsMetaData registry = DeliveryDestinationsMetaData.ofOrWithNew(instance);
|
||||
if(registry.getDestinations().stream().noneMatch(destination -> NAME.equals(destination.getName())))
|
||||
try
|
||||
{
|
||||
registry.withDestination(metadata(config));
|
||||
Class.forName("com.kingsrook.qqq.backend.core.delivery.DeliveryDestinationsMetaData");
|
||||
com.kingsrook.qqq.backend.core.logging.QLogger.getLogger(SlackDeliveryDestination.class)
|
||||
.info("Slack delivery stand-in will be replaced after RC.8; host registration is deferred"); // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
}
|
||||
catch(ClassNotFoundException e)
|
||||
{
|
||||
// RC.6 hosts use this destination directly.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Offer only active connections and membership-checked targets of this user.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DeliveryAttachment.
|
||||
*******************************************************************************/
|
||||
public class DeliveryAttachment implements java.io.Serializable
|
||||
{
|
||||
private String fileName;
|
||||
private String mimeType;
|
||||
private Long size;
|
||||
private String storageTableName;
|
||||
private String storageReference;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get fileName.
|
||||
*******************************************************************************/
|
||||
public String getFileName()
|
||||
{
|
||||
return (fileName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set fileName.
|
||||
*******************************************************************************/
|
||||
public void setFileName(String fileName)
|
||||
{
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With fileName.
|
||||
*******************************************************************************/
|
||||
public DeliveryAttachment withFileName(String fileName)
|
||||
{
|
||||
this.fileName = fileName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get mimeType.
|
||||
*******************************************************************************/
|
||||
public String getMimeType()
|
||||
{
|
||||
return (mimeType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set mimeType.
|
||||
*******************************************************************************/
|
||||
public void setMimeType(String mimeType)
|
||||
{
|
||||
this.mimeType = mimeType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With mimeType.
|
||||
*******************************************************************************/
|
||||
public DeliveryAttachment withMimeType(String mimeType)
|
||||
{
|
||||
this.mimeType = mimeType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get size.
|
||||
*******************************************************************************/
|
||||
public Long getSize()
|
||||
{
|
||||
return (size);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set size.
|
||||
*******************************************************************************/
|
||||
public void setSize(Long size)
|
||||
{
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With size.
|
||||
*******************************************************************************/
|
||||
public DeliveryAttachment withSize(Long size)
|
||||
{
|
||||
this.size = size;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get storageTableName.
|
||||
*******************************************************************************/
|
||||
public String getStorageTableName()
|
||||
{
|
||||
return (storageTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set storageTableName.
|
||||
*******************************************************************************/
|
||||
public void setStorageTableName(String storageTableName)
|
||||
{
|
||||
this.storageTableName = storageTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With storageTableName.
|
||||
*******************************************************************************/
|
||||
public DeliveryAttachment withStorageTableName(String storageTableName)
|
||||
{
|
||||
this.storageTableName = storageTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get storageReference.
|
||||
*******************************************************************************/
|
||||
public String getStorageReference()
|
||||
{
|
||||
return (storageReference);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set storageReference.
|
||||
*******************************************************************************/
|
||||
public void setStorageReference(String storageReference)
|
||||
{
|
||||
this.storageReference = storageReference;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With storageReference.
|
||||
*******************************************************************************/
|
||||
public DeliveryAttachment withStorageReference(String storageReference)
|
||||
{
|
||||
this.storageReference = storageReference;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DeliveryDestinationContext.
|
||||
*******************************************************************************/
|
||||
public class DeliveryDestinationContext implements java.io.Serializable
|
||||
{
|
||||
private QDeliveryDestinationMetaData destination;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get destination.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData getDestination()
|
||||
{
|
||||
return (destination);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set destination.
|
||||
*******************************************************************************/
|
||||
public void setDestination(QDeliveryDestinationMetaData destination)
|
||||
{
|
||||
this.destination = destination;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With destination.
|
||||
*******************************************************************************/
|
||||
public DeliveryDestinationContext withDestination(QDeliveryDestinationMetaData destination)
|
||||
{
|
||||
this.destination = destination;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner-scoped output delivery provider.
|
||||
*******************************************************************************/
|
||||
public interface DeliveryDestinationInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the next target in the current owner session.
|
||||
*******************************************************************************/
|
||||
DeliveryTargetStep describeTargets(DeliveryDestinationContext context, Map<String, Serializable> chosen) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate a complete target before sending.
|
||||
*******************************************************************************/
|
||||
default List<String> validate(DeliveryRequest request) throws QException
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliver idempotently using the request key.
|
||||
*******************************************************************************/
|
||||
DeliveryResult deliver(DeliveryRequest request) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reconcile a previous delivery when supported.
|
||||
*******************************************************************************/
|
||||
default Optional<DeliveryResult> lookup(String idempotencyKey) throws QException
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DeliveryRequest.
|
||||
*******************************************************************************/
|
||||
public class DeliveryRequest implements java.io.Serializable
|
||||
{
|
||||
private String destinationName;
|
||||
private String ownerUserId;
|
||||
private String sourceType;
|
||||
private String sourceKey;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String link;
|
||||
private String idempotencyKey;
|
||||
private Map<String, Serializable> targetValues = Map.of();
|
||||
private List<DeliveryAttachment> attachments = List.of();
|
||||
private DataClassification contentClassification;
|
||||
private List<String> restrictedFieldLabels = List.of();
|
||||
private boolean leaveOutRestricted;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get destinationName.
|
||||
*******************************************************************************/
|
||||
public String getDestinationName()
|
||||
{
|
||||
return (destinationName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set destinationName.
|
||||
*******************************************************************************/
|
||||
public void setDestinationName(String destinationName)
|
||||
{
|
||||
this.destinationName = destinationName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With destinationName.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withDestinationName(String destinationName)
|
||||
{
|
||||
this.destinationName = destinationName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get ownerUserId.
|
||||
*******************************************************************************/
|
||||
public String getOwnerUserId()
|
||||
{
|
||||
return (ownerUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set ownerUserId.
|
||||
*******************************************************************************/
|
||||
public void setOwnerUserId(String ownerUserId)
|
||||
{
|
||||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With ownerUserId.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withOwnerUserId(String ownerUserId)
|
||||
{
|
||||
this.ownerUserId = ownerUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get sourceType.
|
||||
*******************************************************************************/
|
||||
public String getSourceType()
|
||||
{
|
||||
return (sourceType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set sourceType.
|
||||
*******************************************************************************/
|
||||
public void setSourceType(String sourceType)
|
||||
{
|
||||
this.sourceType = sourceType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With sourceType.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withSourceType(String sourceType)
|
||||
{
|
||||
this.sourceType = sourceType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get sourceKey.
|
||||
*******************************************************************************/
|
||||
public String getSourceKey()
|
||||
{
|
||||
return (sourceKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set sourceKey.
|
||||
*******************************************************************************/
|
||||
public void setSourceKey(String sourceKey)
|
||||
{
|
||||
this.sourceKey = sourceKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With sourceKey.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withSourceKey(String sourceKey)
|
||||
{
|
||||
this.sourceKey = sourceKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get title.
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set title.
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With title.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get summary.
|
||||
*******************************************************************************/
|
||||
public String getSummary()
|
||||
{
|
||||
return (summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set summary.
|
||||
*******************************************************************************/
|
||||
public void setSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With summary.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get link.
|
||||
*******************************************************************************/
|
||||
public String getLink()
|
||||
{
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set link.
|
||||
*******************************************************************************/
|
||||
public void setLink(String link)
|
||||
{
|
||||
this.link = link;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With link.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withLink(String link)
|
||||
{
|
||||
this.link = link;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public String getIdempotencyKey()
|
||||
{
|
||||
return (idempotencyKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public void setIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With idempotencyKey.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get targetValues.
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getTargetValues()
|
||||
{
|
||||
return (targetValues);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set targetValues.
|
||||
*******************************************************************************/
|
||||
public void setTargetValues(Map<String, Serializable> targetValues)
|
||||
{
|
||||
this.targetValues = targetValues;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With targetValues.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withTargetValues(Map<String, Serializable> targetValues)
|
||||
{
|
||||
this.targetValues = targetValues;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get attachments.
|
||||
*******************************************************************************/
|
||||
public List<DeliveryAttachment> getAttachments()
|
||||
{
|
||||
return (attachments);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set attachments.
|
||||
*******************************************************************************/
|
||||
public void setAttachments(List<DeliveryAttachment> attachments)
|
||||
{
|
||||
this.attachments = attachments;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With attachments.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withAttachments(List<DeliveryAttachment> attachments)
|
||||
{
|
||||
this.attachments = attachments;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get contentClassification.
|
||||
*******************************************************************************/
|
||||
public DataClassification getContentClassification()
|
||||
{
|
||||
return (contentClassification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set contentClassification.
|
||||
*******************************************************************************/
|
||||
public void setContentClassification(DataClassification contentClassification)
|
||||
{
|
||||
this.contentClassification = contentClassification;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With contentClassification.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withContentClassification(DataClassification contentClassification)
|
||||
{
|
||||
this.contentClassification = contentClassification;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get restrictedFieldLabels.
|
||||
*******************************************************************************/
|
||||
public List<String> getRestrictedFieldLabels()
|
||||
{
|
||||
return (restrictedFieldLabels);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set restrictedFieldLabels.
|
||||
*******************************************************************************/
|
||||
public void setRestrictedFieldLabels(List<String> restrictedFieldLabels)
|
||||
{
|
||||
this.restrictedFieldLabels = restrictedFieldLabels;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With restrictedFieldLabels.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withRestrictedFieldLabels(List<String> restrictedFieldLabels)
|
||||
{
|
||||
this.restrictedFieldLabels = restrictedFieldLabels;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get leaveOutRestricted.
|
||||
*******************************************************************************/
|
||||
public boolean getLeaveOutRestricted()
|
||||
{
|
||||
return (leaveOutRestricted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set leaveOutRestricted.
|
||||
*******************************************************************************/
|
||||
public void setLeaveOutRestricted(boolean leaveOutRestricted)
|
||||
{
|
||||
this.leaveOutRestricted = leaveOutRestricted;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With leaveOutRestricted.
|
||||
*******************************************************************************/
|
||||
public DeliveryRequest withLeaveOutRestricted(boolean leaveOutRestricted)
|
||||
{
|
||||
this.leaveOutRestricted = leaveOutRestricted;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DeliveryResult.
|
||||
*******************************************************************************/
|
||||
public class DeliveryResult implements java.io.Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Recorded delivery outcome.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
public enum Status
|
||||
{
|
||||
DELIVERED, QUEUED, REFUSED, FAILED_RETRYABLE, FAILED, UNAUTHORIZED
|
||||
}
|
||||
|
||||
private Status status;
|
||||
private String externalReference;
|
||||
private List<String> reasons = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get status.
|
||||
*******************************************************************************/
|
||||
public Status getStatus()
|
||||
{
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set status.
|
||||
*******************************************************************************/
|
||||
public void setStatus(Status status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With status.
|
||||
*******************************************************************************/
|
||||
public DeliveryResult withStatus(Status status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get externalReference.
|
||||
*******************************************************************************/
|
||||
public String getExternalReference()
|
||||
{
|
||||
return (externalReference);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set externalReference.
|
||||
*******************************************************************************/
|
||||
public void setExternalReference(String externalReference)
|
||||
{
|
||||
this.externalReference = externalReference;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With externalReference.
|
||||
*******************************************************************************/
|
||||
public DeliveryResult withExternalReference(String externalReference)
|
||||
{
|
||||
this.externalReference = externalReference;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get reasons.
|
||||
*******************************************************************************/
|
||||
public List<String> getReasons()
|
||||
{
|
||||
return (reasons);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set reasons.
|
||||
*******************************************************************************/
|
||||
public void setReasons(List<String> reasons)
|
||||
{
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With reasons.
|
||||
*******************************************************************************/
|
||||
public DeliveryResult withReasons(List<String> reasons)
|
||||
{
|
||||
this.reasons = reasons;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DeliveryTargetOption.
|
||||
*******************************************************************************/
|
||||
public class DeliveryTargetOption implements java.io.Serializable
|
||||
{
|
||||
private String value;
|
||||
private String label;
|
||||
private String description;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get value.
|
||||
*******************************************************************************/
|
||||
public String getValue()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set value.
|
||||
*******************************************************************************/
|
||||
public void setValue(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With value.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetOption withValue(String value)
|
||||
{
|
||||
this.value = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set label.
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With label.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetOption withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get description.
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set description.
|
||||
*******************************************************************************/
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With description.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetOption withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DeliveryTargetStep.
|
||||
*******************************************************************************/
|
||||
public class DeliveryTargetStep implements java.io.Serializable
|
||||
{
|
||||
private String name;
|
||||
private String label;
|
||||
private List<DeliveryTargetOption> options = List.of();
|
||||
private boolean freeText;
|
||||
private boolean complete;
|
||||
private String summary;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get name.
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set name.
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With name.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetStep withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set label.
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With label.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetStep withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get options.
|
||||
*******************************************************************************/
|
||||
public List<DeliveryTargetOption> getOptions()
|
||||
{
|
||||
return (options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set options.
|
||||
*******************************************************************************/
|
||||
public void setOptions(List<DeliveryTargetOption> options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With options.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetStep withOptions(List<DeliveryTargetOption> options)
|
||||
{
|
||||
this.options = options;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get freeText.
|
||||
*******************************************************************************/
|
||||
public boolean getFreeText()
|
||||
{
|
||||
return (freeText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set freeText.
|
||||
*******************************************************************************/
|
||||
public void setFreeText(boolean freeText)
|
||||
{
|
||||
this.freeText = freeText;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With freeText.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetStep withFreeText(boolean freeText)
|
||||
{
|
||||
this.freeText = freeText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get complete.
|
||||
*******************************************************************************/
|
||||
public boolean getComplete()
|
||||
{
|
||||
return (complete);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set complete.
|
||||
*******************************************************************************/
|
||||
public void setComplete(boolean complete)
|
||||
{
|
||||
this.complete = complete;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With complete.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetStep withComplete(boolean complete)
|
||||
{
|
||||
this.complete = complete;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get summary.
|
||||
*******************************************************************************/
|
||||
public String getSummary()
|
||||
{
|
||||
return (summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set summary.
|
||||
*******************************************************************************/
|
||||
public void setSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With summary.
|
||||
*******************************************************************************/
|
||||
public DeliveryTargetStep withSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QDeliveryDestinationMetaData.
|
||||
*******************************************************************************/
|
||||
public class QDeliveryDestinationMetaData implements java.io.Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Policy for content above the destination ceiling.
|
||||
*******************************************************************************/
|
||||
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
public enum AboveMaxClassification
|
||||
{
|
||||
REFUSE, LINK_ONLY
|
||||
}
|
||||
|
||||
private String name;
|
||||
private String label;
|
||||
private String icon;
|
||||
private String permissionName;
|
||||
private String emailProviderName;
|
||||
private Integer order = 0;
|
||||
private QCodeReference codeReference;
|
||||
private DataClassification maxClassification = DataClassification.PUBLIC;
|
||||
private Long maxAttachmentBytes;
|
||||
private boolean allowBearerLinks;
|
||||
private AboveMaxClassification aboveMaxClassification = AboveMaxClassification.REFUSE;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get name.
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set name.
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With name.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set label.
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With label.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get icon.
|
||||
*******************************************************************************/
|
||||
public String getIcon()
|
||||
{
|
||||
return (icon);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set icon.
|
||||
*******************************************************************************/
|
||||
public void setIcon(String icon)
|
||||
{
|
||||
this.icon = icon;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With icon.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withIcon(String icon)
|
||||
{
|
||||
this.icon = icon;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get permissionName.
|
||||
*******************************************************************************/
|
||||
public String getPermissionName()
|
||||
{
|
||||
return (permissionName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set permissionName.
|
||||
*******************************************************************************/
|
||||
public void setPermissionName(String permissionName)
|
||||
{
|
||||
this.permissionName = permissionName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With permissionName.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withPermissionName(String permissionName)
|
||||
{
|
||||
this.permissionName = permissionName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get emailProviderName.
|
||||
*******************************************************************************/
|
||||
public String getEmailProviderName()
|
||||
{
|
||||
return (emailProviderName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set emailProviderName.
|
||||
*******************************************************************************/
|
||||
public void setEmailProviderName(String emailProviderName)
|
||||
{
|
||||
this.emailProviderName = emailProviderName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With emailProviderName.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withEmailProviderName(String emailProviderName)
|
||||
{
|
||||
this.emailProviderName = emailProviderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get order.
|
||||
*******************************************************************************/
|
||||
public Integer getOrder()
|
||||
{
|
||||
return (order);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set order.
|
||||
*******************************************************************************/
|
||||
public void setOrder(Integer order)
|
||||
{
|
||||
this.order = order;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With order.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withOrder(Integer order)
|
||||
{
|
||||
this.order = order;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get codeReference.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getCodeReference()
|
||||
{
|
||||
return (codeReference);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set codeReference.
|
||||
*******************************************************************************/
|
||||
public void setCodeReference(QCodeReference codeReference)
|
||||
{
|
||||
this.codeReference = codeReference;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With codeReference.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withCodeReference(QCodeReference codeReference)
|
||||
{
|
||||
this.codeReference = codeReference;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get maxClassification.
|
||||
*******************************************************************************/
|
||||
public DataClassification getMaxClassification()
|
||||
{
|
||||
return (maxClassification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set maxClassification.
|
||||
*******************************************************************************/
|
||||
public void setMaxClassification(DataClassification maxClassification)
|
||||
{
|
||||
this.maxClassification = maxClassification;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With maxClassification.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withMaxClassification(DataClassification maxClassification)
|
||||
{
|
||||
this.maxClassification = maxClassification;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get maxAttachmentBytes.
|
||||
*******************************************************************************/
|
||||
public Long getMaxAttachmentBytes()
|
||||
{
|
||||
return (maxAttachmentBytes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set maxAttachmentBytes.
|
||||
*******************************************************************************/
|
||||
public void setMaxAttachmentBytes(Long maxAttachmentBytes)
|
||||
{
|
||||
this.maxAttachmentBytes = maxAttachmentBytes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With maxAttachmentBytes.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withMaxAttachmentBytes(Long maxAttachmentBytes)
|
||||
{
|
||||
this.maxAttachmentBytes = maxAttachmentBytes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get allowBearerLinks.
|
||||
*******************************************************************************/
|
||||
public boolean getAllowBearerLinks()
|
||||
{
|
||||
return (allowBearerLinks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set allowBearerLinks.
|
||||
*******************************************************************************/
|
||||
public void setAllowBearerLinks(boolean allowBearerLinks)
|
||||
{
|
||||
this.allowBearerLinks = allowBearerLinks;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With allowBearerLinks.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withAllowBearerLinks(boolean allowBearerLinks)
|
||||
{
|
||||
this.allowBearerLinks = allowBearerLinks;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get aboveMaxClassification.
|
||||
*******************************************************************************/
|
||||
public AboveMaxClassification getAboveMaxClassification()
|
||||
{
|
||||
return (aboveMaxClassification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set aboveMaxClassification.
|
||||
*******************************************************************************/
|
||||
public void setAboveMaxClassification(AboveMaxClassification aboveMaxClassification)
|
||||
{
|
||||
this.aboveMaxClassification = aboveMaxClassification;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With aboveMaxClassification.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData withAboveMaxClassification(AboveMaxClassification aboveMaxClassification)
|
||||
{
|
||||
this.aboveMaxClassification = aboveMaxClassification;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-slack.yaml (the QQQ help-bundle format) to the Slack app
|
||||
** and its slack_* tables: what each table is for and how its rows get created,
|
||||
** shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned QQQ
|
||||
** release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class SlackHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-slack.yaml";
|
||||
public static final String SLOT = "help";
|
||||
public static final String APP_NAME = "slack";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = SlackHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-slack".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every slack_* table in the instance. A
|
||||
** host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(APP_NAME);
|
||||
JsonNode appEntry = entries.get("app:" + APP_NAME + ";slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecrets;
|
||||
@@ -36,7 +35,6 @@ import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qbits.slack.transport.SlackResponse;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
@@ -61,10 +59,8 @@ public final class SlackConnect
|
||||
|
||||
/*******************************************************************************
|
||||
** Start only for the signed-in user, never an automated or system session.
|
||||
** An environment without a public URL or without the Slack app secrets is
|
||||
** refused in words the user can act on, before any state is issued.
|
||||
*******************************************************************************/
|
||||
public static String startUrl(String userId, String mode) throws QException
|
||||
public static String startUrl(String userId, String mode)
|
||||
{
|
||||
QSession session = session();
|
||||
if(!session.getUser().getIdReference().equals(userId) || !session.hasPermission("slack.connect")
|
||||
@@ -73,11 +69,6 @@ public final class SlackConnect
|
||||
throw (new IllegalArgumentException("Slack connect is not permitted"));
|
||||
}
|
||||
SlackQBitConfig config = SlackRuntime.config();
|
||||
String notSetUp = notSetUp(config);
|
||||
if(notSetUp != null)
|
||||
{
|
||||
throw (new QUserFacingException(notSetUp));
|
||||
}
|
||||
String redirect = redirectUri(config);
|
||||
String state = SlackOAuthState.issue(config, session, userId, tenant(config, session), mode);
|
||||
return ("https://slack.com/oauth/v2/authorize?client_id=" + encode(SlackSecrets.get(config, config.getClientIdEnvName()))
|
||||
@@ -283,52 +274,14 @@ public final class SlackConnect
|
||||
return (session);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Why Slack cannot be connected in this environment, or null when it can.
|
||||
** Connecting needs two things from the host: the public URL Slack sends the
|
||||
** browser back to, and the Slack app secrets (client id, client secret and
|
||||
** token key). The sentence says which is missing and names the environment
|
||||
** variables, never a value.
|
||||
*******************************************************************************/
|
||||
public static String notSetUp(SlackQBitConfig config)
|
||||
{
|
||||
boolean noUrl = config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank();
|
||||
List<String> secrets = Stream.of(config.getClientIdEnvName(), config.getClientSecretEnvName(), config.getTokenKeyEnvName())
|
||||
.filter(name -> !present(config, name)).toList();
|
||||
if(!noUrl && secrets.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String missing = noUrl && !secrets.isEmpty() ? "the public URL and the Slack app secrets are not configured"
|
||||
: noUrl ? "the public URL is not configured" : "the Slack app secrets are not configured";
|
||||
return ("Slack is not set up in this environment: " + missing
|
||||
+ (secrets.isEmpty() ? "." : " (missing " + String.join(", ", secrets) + ")."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** True when a named secret resolves to a value. The value is not kept.
|
||||
*******************************************************************************/
|
||||
private static boolean present(SlackQBitConfig config, String name)
|
||||
{
|
||||
try
|
||||
{
|
||||
SlackSecrets.get(config, name);
|
||||
return (true);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact redirect URI shared by start and exchange.
|
||||
*******************************************************************************/
|
||||
private static String redirectUri(SlackQBitConfig config)
|
||||
{
|
||||
if(config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank())
|
||||
if(config.getPublicBaseUrl() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack is not set up in this environment: the public URL is not configured."));
|
||||
throw (new IllegalArgumentException("Slack public URL is required"));
|
||||
}
|
||||
return (config.getPublicBaseUrl().replaceAll("/+$", "") + config.getOauthCallbackPath());
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ import io.javalin.http.Context;
|
||||
*******************************************************************************/
|
||||
public class SlackOAuthRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
/** The Slack section of the host UI's Preferences page. */
|
||||
public static final String RETURN_PATH = "/app/preferences?section=slack";
|
||||
|
||||
private QInstance instance;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -140,18 +137,7 @@ public class SlackOAuthRouteProvider implements QJavalinRouteProviderInterface
|
||||
QContext.init(previous);
|
||||
}
|
||||
context.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer");
|
||||
context.redirect(returnUrl(SlackRuntime.config(instance).getPublicBaseUrl(), error));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Where the browser lands after Slack: the user's Slack preferences in the
|
||||
** host UI (/app/preferences, like every other /app path this qbit links to),
|
||||
** with a fixed outcome code the page turns into words. A host without a
|
||||
** public URL gets a path on the same origin.
|
||||
*******************************************************************************/
|
||||
public static String returnUrl(String publicBaseUrl, String error)
|
||||
{
|
||||
return ((publicBaseUrl == null ? "" : publicBaseUrl.replaceAll("/+$", ""))
|
||||
+ RETURN_PATH + (error == null ? "&result=connected" : "&error=" + error));
|
||||
context.redirect(SlackRuntime.config(instance).getPublicBaseUrl().replaceAll("/+$", "")
|
||||
+ "/preferences?section=slack" + (error == null ? "&result=connected" : "&error=" + error));
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,6 @@ import org.json.JSONObject;
|
||||
*******************************************************************************/
|
||||
public final class SlackOAuthState
|
||||
{
|
||||
/** How long an issued state, and so the authorize address that carries it, stays valid. */
|
||||
public static final int LIFETIME_SECONDS = 600;
|
||||
|
||||
private static final Map<String, Long> NONCES = new HashMap<>();
|
||||
private static final int CAPACITY = 10_000;
|
||||
|
||||
@@ -75,7 +72,7 @@ public final class SlackOAuthState
|
||||
throw (new IllegalArgumentException("Slack connect unavailable"));
|
||||
}
|
||||
String nonce = UUID.randomUUID().toString();
|
||||
long expiry = now + LIFETIME_SECONDS;
|
||||
long expiry = now + 600;
|
||||
String payload = encode(new JSONObject().put("user", user).put("tenant", Objects.toString(tenant, ""))
|
||||
.put("session", session.getUuid()).put("nonce", nonce).put("mode", mode).put("expiry", expiry).toString().getBytes(StandardCharsets.UTF_8));
|
||||
String signed = payload + "." + encode(sign(config, payload));
|
||||
|
||||
@@ -27,16 +27,15 @@ import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.slack.support.SlackRuntime;
|
||||
import com.kingsrook.qbits.slack.template.VariableSchema;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueTokens;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValues;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
|
||||
/*******************************************************************************
|
||||
** Refuse restricted content before persistence. Field classification comes
|
||||
** from core ClassifiedValues (which honors a host's audit-value protector);
|
||||
** tokenized restricted values become core ClassifiedValueTokens.changed(), a
|
||||
** deliberately non-reversible marker, never a hash of a low-entropy value.
|
||||
** Refuse restricted content before persistence. Uses RC.6 field classifications.
|
||||
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.8): ClassifiedValues and
|
||||
** ClassifiedValueTokens are absent in RC.6. Restricted tokens use changed:v1,
|
||||
** a deliberately non-reversible marker, never a hash of a low-entropy value.
|
||||
*******************************************************************************/
|
||||
public final class SlackPayloadGuard
|
||||
{
|
||||
@@ -89,10 +88,7 @@ public final class SlackPayloadGuard
|
||||
{
|
||||
throw (new IllegalArgumentException("Unknown classified field"));
|
||||
}
|
||||
if(ClassifiedValues.isClassified(table, field))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
classification = DataClassification.max(classification, AiDataClassifications.ofField(table, field));
|
||||
}
|
||||
return (classification != null && classification.isAtLeast(DataClassification.PII));
|
||||
}
|
||||
@@ -127,7 +123,7 @@ public final class SlackPayloadGuard
|
||||
{
|
||||
throw (new RestrictedVariableException(declaration.name()));
|
||||
}
|
||||
safe.put(entry.getKey(), ClassifiedValueTokens.changed());
|
||||
safe.put(entry.getKey(), "changed:v1");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -149,7 +145,7 @@ public final class SlackPayloadGuard
|
||||
{
|
||||
throw (new RestrictedVariableException(declaration.name()));
|
||||
}
|
||||
safe.put(declaration.name(), ClassifiedValueTokens.changed());
|
||||
safe.put(declaration.name(), "changed:v1");
|
||||
}
|
||||
}
|
||||
return (safe);
|
||||
@@ -172,7 +168,7 @@ public final class SlackPayloadGuard
|
||||
{
|
||||
throw (new IllegalArgumentException("Restricted template variable"));
|
||||
}
|
||||
return (ClassifiedValueTokens.changed());
|
||||
return ("changed:v1");
|
||||
}
|
||||
return (sanitize(classified.value(), tokenize));
|
||||
}
|
||||
|
||||
@@ -74,25 +74,10 @@ 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.
|
||||
*******************************************************************************/
|
||||
public static String connect(boolean install) throws QException
|
||||
public static String connect(boolean install)
|
||||
{
|
||||
return (SlackConnect.startUrl(owner(), install ? "user+bot" : "user"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +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 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."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core has no redirect field on an action result (checked through QQQ
|
||||
** 4.1.0-RC.31), so Connect and Install answer with the authorize address as
|
||||
** the whole message and nothing else. qqq-frontend-next sends the browser to
|
||||
** a message that is only an https address instead of showing it; any other
|
||||
** word in the message would turn it back into text to read.
|
||||
** 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()));
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import com.kingsrook.qbits.slack.SlackQBitConfig;
|
||||
import com.kingsrook.qbits.slack.delivery.SlackDispatcher;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackConnect;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackOAuthState;
|
||||
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplates;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
@@ -43,9 +42,6 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
@@ -71,9 +67,6 @@ public final class SlackProcesses
|
||||
public static final String MAINTAIN = "slack_readiness";
|
||||
public static final String DISPATCH = "slack_dispatch";
|
||||
|
||||
/** The one screen of Connect Slack and Install: its HTML is the process value "continue.html". */
|
||||
public static final String CONTINUE_STEP = "continue";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -117,63 +110,31 @@ public final class SlackProcesses
|
||||
|
||||
/*******************************************************************************
|
||||
** OAuth starts without requiring an existing connection or selected record.
|
||||
** The one screen is an HTML component: a frontend step shows only the
|
||||
** components it declares, so a view field alone renders an empty screen.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData connectAction(String name, String label)
|
||||
{
|
||||
return (new QProcessMetaData().withName(name).withLabel(label).withTableName(SlackTables.CONNECTION)
|
||||
.withMinInputRecords(0).withPermissionRules(SlackTables.permissionRules())
|
||||
.withStep(new QBackendStepMetaData().withName("authorize").withCode(new QCodeReference(ConnectStep.class)))
|
||||
.withStep(new QFrontendStepMetaData().withName(CONTINUE_STEP).withLabel("Continue to Slack")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HTML))));
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Connect Slack")
|
||||
.withViewField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("authorizeUrl", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING).withLabel("Open this Slack authorization URL"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Put the way to Slack on the process screen through the standard UI API.
|
||||
** Return the authorize URL through the standard process UI API.
|
||||
*******************************************************************************/
|
||||
public static class ConnectStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No remote mutation happens until the user approves at Slack. The screen
|
||||
** gets a link, not an address to copy; the address itself stays available
|
||||
** to API clients in the authorizeUrl value.
|
||||
** No remote mutation happens until the user approves at Slack.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output)
|
||||
{
|
||||
boolean install = INSTALL.equals(input.getProcessName());
|
||||
String url = com.kingsrook.qbits.slack.preferences.SlackPreferences.connect(install);
|
||||
output.addValue("authorizeUrl", url);
|
||||
output.addValue(CONTINUE_STEP + ".html", continueHtml(url, install));
|
||||
output.addValue("authorizeUrl", com.kingsrook.qbits.slack.preferences.SlackPreferences.connect(INSTALL.equals(input.getProcessName())));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** What the screen says: one sentence about what happens next, the link to
|
||||
** Slack's consent page (same tab, so Slack can return the browser here), and
|
||||
** when the link stops working. The link carries its own style because a host
|
||||
** UI may render links in process HTML like the text around them.
|
||||
*******************************************************************************/
|
||||
static String continueHtml(String authorizeUrl, boolean install)
|
||||
{
|
||||
String paragraph = "<p style=\"margin:0 0 12px\">";
|
||||
String what = install ? "Slack will ask you to approve installing this application for your organization and connecting your own Slack account."
|
||||
: "Slack will ask you to approve connecting your Slack account to this application.";
|
||||
return (paragraph + what + " Afterwards you return to your Slack preferences, which show the result.</p>"
|
||||
+ paragraph + "<a href=\"" + escapeHtml(authorizeUrl) + "\" style=\"display:inline-block;padding:8px 16px;border-radius:6px;"
|
||||
+ "background:#4A154B;color:#ffffff;font-weight:600;text-decoration:none\">Continue to Slack</a></p>"
|
||||
+ "<p style=\"margin:0\">This link expires in " + SlackOAuthState.LIFETIME_SECONDS / 60 + " minutes. If it has expired, run this action again.</p>");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape text for an HTML attribute or element.
|
||||
*******************************************************************************/
|
||||
private static String escapeHtml(String text)
|
||||
{
|
||||
return (text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The scheduler's entry point.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -64,29 +64,9 @@ public class SlackHealth implements HealthIndicator
|
||||
public HealthCheckResult check(QInstance instance)
|
||||
{
|
||||
var readiness = SlackReadiness.stored(instance);
|
||||
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;
|
||||
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;
|
||||
return (new HealthCheckResult().withStatus(status)
|
||||
.withDetail("configured", true)
|
||||
.withDetail("checks", readiness.checks()));
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,6 @@ 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<>());
|
||||
@@ -89,17 +87,6 @@ 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)
|
||||
@@ -125,33 +112,6 @@ 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -1,554 +1,53 @@
|
||||
bundle: qbit-slack
|
||||
version: ${project.version}
|
||||
version: 1
|
||||
layer: QBIT
|
||||
# Help for the Slack app and every slack_* table, in the QQQ help-bundle format
|
||||
# (qqq#1198). Each table entry says what the table is for and how its rows get
|
||||
# created, so an empty table explains itself. SlackHelp applies it until the core
|
||||
# HelpBundles registry ships in a QQQ release.
|
||||
# Summaries stay under 120 characters (SlackTablesGuardrailTest).
|
||||
entries:
|
||||
- key: app:slack;slot:help
|
||||
summary: "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.
|
||||
- key: app:slack;slot:help
|
||||
summary: "Send Slack messages from QQQ through a durable outbox, as the person who sent them."
|
||||
content: |
|
||||
Set up in this order: 1) Install the Slack app for your organization (Connections, "Install for my organization") so a Workspace row exists; 2) each person uses "Connect Slack" so messages go out as them; 3) register the channels and user groups your processes post to, then run "Check channels"; 4) review the built-in Templates. Every send is queued in Messages and retried; Attempts and Interactions are the audit trail. The Readiness section on the home shows what still needs attention.
|
||||
|
||||
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.
|
||||
- key: table:slack_workspace;slot:help
|
||||
summary: "A Slack workspace this system is installed in, with its bot token status and readiness."
|
||||
content: |
|
||||
**How rows get created:** an administrator runs "Install for my organization" (Connections) and approves the app in Slack; the OAuth callback creates the row and stores the bot token encrypted. Nothing is typed in by hand. Use "Check readiness" to re-check the bot token, scopes and registered channels, and "Uninstall" to remove the app.
|
||||
|
||||
- key: table:slack_connection;slot:help
|
||||
summary: "A person's own link to a workspace, so their Slack messages are sent as them."
|
||||
content: |
|
||||
**How rows get created:** each user runs "Connect Slack" and approves access in Slack; the OAuth callback creates the row and stores their token encrypted. An administrator can see everyone's. "Send me a test message" checks it works, and "Disconnect" revokes it. A workspace must be installed first.
|
||||
|
||||
- key: table:slack_channel;slot:help
|
||||
summary: "A Slack channel registered under a short name, so processes can post to it and see its status."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New: pick the workspace, give it a short name that senders use, and paste the Slack channel ID (open the channel in Slack, then About, then the ID at the bottom). Invite the bot with /invite @YourApp, then run "Check channels": it fills in the Slack name, private, bot is member, archived, purpose and checked time. Readiness re-checks enabled channels on a schedule, and messages are sent only to enabled channels the bot can post to.
|
||||
|
||||
- key: table:slack_user_group;slot:help
|
||||
summary: "A Slack user group registered under a short name, mentioned when a message goes to the group."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New: pick the workspace, give it a short name, paste the Slack user group ID (starts with S) and the ID of the channel where the group is mentioned. The handle is filled in by Check readiness when the app has the optional usergroups:read scope, and then shows in the mention as @handle. A group without a user group ID or channel cannot be sent to.
|
||||
|
||||
- key: table:slack_template;slot:help
|
||||
summary: "Message templates (text and Slack blocks) with declared variables, used by name when sending."
|
||||
content: |
|
||||
**How rows get created:** the qbit adds three built-ins at startup and on each readiness run: notification, digest and delivery (never overwritten once you edit them). Administrators add their own with New: variables must be declared, links must be URLs, and nothing classified as PHI or PII is allowed. A sender names the template and passes variables.
|
||||
|
||||
- key: table:slack_message;slot:help
|
||||
summary: "The outbox: every Slack message queued, sent, retried or dead-lettered, with who sent it."
|
||||
content: |
|
||||
**How rows get created:** automatically. Anything that sends to Slack (a process, an alert, a delivery destination, a test message) queues a row here first as its owner; the dispatcher then posts it and records the outcome. It starts empty on a new installation and is read-only history; nothing is typed in by hand.
|
||||
|
||||
- key: table:slack_message_attempt;slot:help
|
||||
summary: "One row per delivery attempt of a message: outcome, Slack error and timing."
|
||||
content: |
|
||||
**How rows get created:** automatically, by the dispatcher, each time it tries to post a message. Use it to see why a message is retrying or failed. It is audit history and is never edited.
|
||||
|
||||
- key: table:slack_interaction;slot:help
|
||||
summary: "Audit of clicks on message buttons and Slack events, and what each one did."
|
||||
content: |
|
||||
**How rows get created:** automatically, when someone clicks a button on a Slack message or Slack sends a revocation event to the callback URL. Each row records who acted, the action and the outcome (the action runs as the clicking user). It is audit history and is never edited.
|
||||
@@ -1 +0,0 @@
|
||||
version=${project.version}
|
||||
@@ -32,6 +32,7 @@ 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;
|
||||
@@ -296,7 +297,7 @@ class SlackAppTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Personal section exposes only permitted actions.
|
||||
** RC.6 skips registration; personal section exposes only permitted actions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void preferencesAndConnect() throws Exception
|
||||
@@ -304,6 +305,7 @@ 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);
|
||||
|
||||
@@ -1,222 +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;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackConnect;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackOAuthState;
|
||||
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
|
||||
import com.kingsrook.qbits.slack.processes.SlackProcesses;
|
||||
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
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.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** What a person sees when they start a Slack connection: a link to follow on
|
||||
** the process screen, an address-only answer the Preferences panel can send
|
||||
** the browser to, a landing page that exists, and a plain refusal when Slack
|
||||
** is not set up in the environment.
|
||||
*******************************************************************************/
|
||||
class SlackConnectScreenTest
|
||||
{
|
||||
private static final String CLIENT_ID_CANARY = "client-id-canary-value";
|
||||
|
||||
/*******************************************************************************
|
||||
** A configured environment: a public URL and every Slack app secret.
|
||||
*******************************************************************************/
|
||||
private static SlackTestEnv configured() throws Exception
|
||||
{
|
||||
return (SlackTestEnv.create(false, c -> c.withPublicBaseUrl("https://example.test")
|
||||
.withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "fake-application-secret")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Connect and Install each end on a screen with content. A frontend step
|
||||
** shows only its declared components, so a step without one is an empty
|
||||
** screen with a Return button.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void connectAndInstallScreensDeclareWhatTheyShow() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = configured())
|
||||
{
|
||||
for(String name : List.of(SlackProcesses.CONNECT, SlackProcesses.INSTALL))
|
||||
{
|
||||
List<QFrontendStepMetaData> screens = env.instance().getProcess(name).getStepList().stream()
|
||||
.filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast).toList();
|
||||
assertThat(screens).as(name).hasSize(1);
|
||||
assertThat(screens.getFirst().getName()).isEqualTo(SlackProcesses.CONTINUE_STEP);
|
||||
assertThat(screens.getFirst().getLabel()).isEqualTo("Continue to Slack");
|
||||
assertThat(screens.getFirst().getComponents()).as(name + " screen components").isNotEmpty()
|
||||
.extracting(QFrontendComponentMetaData::getType).containsExactly(QComponentType.HTML);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The screen's HTML is a sentence, a link named Continue to Slack whose
|
||||
** target is the authorize address, and the lifetime of the link read from
|
||||
** the state. The address is in the link target only, never text to copy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theScreenLinksToSlackAndSaysWhenTheLinkExpires() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = configured())
|
||||
{
|
||||
env.asOwner("alice").withPermissions(SlackPermissions.CONNECT, SlackPermissions.INSTALL);
|
||||
RunBackendStepOutput connect = new RunBackendStepOutput();
|
||||
new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.CONNECT), connect);
|
||||
String url = connect.getValueString("authorizeUrl");
|
||||
String html = connect.getValueString(SlackProcesses.CONTINUE_STEP + ".html");
|
||||
assertThat(url).startsWith("https://slack.com/oauth/v2/authorize?").contains("&state=");
|
||||
assertThat(html).contains("<a href=\"" + url.replace("&", "&") + "\"").contains(">Continue to Slack</a>");
|
||||
assertThat(html).doesNotContain("target=");
|
||||
assertThat(html).contains("approve connecting your Slack account");
|
||||
assertThat(SlackOAuthState.LIFETIME_SECONDS).isEqualTo(600);
|
||||
assertThat(html).contains("This link expires in 10 minutes.");
|
||||
assertThat(html.replaceAll("<[^>]*>", " ")).as("visible text").doesNotContain("https://", "slack.com", "state=");
|
||||
|
||||
RunBackendStepOutput install = new RunBackendStepOutput();
|
||||
new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.INSTALL), install);
|
||||
assertThat(install.getValueString(SlackProcesses.CONTINUE_STEP + ".html"))
|
||||
.contains("installing this application for your organization", ">Continue to Slack</a>", "scope=chat");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run as the UI runs it: the process stops on its one screen and the
|
||||
** screen's HTML is among the values the UI API returns for it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void installRunStopsOnTheContinueScreenWithItsHtml() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = configured())
|
||||
{
|
||||
env.asOwner("alice").setPermissions(Set.of(SlackPermissions.CONNECT, SlackPermissions.INSTALL));
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(SlackProcesses.INSTALL);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getProcessState().getNextStepName()).hasValue(SlackProcesses.CONTINUE_STEP);
|
||||
assertThat(String.valueOf(output.getValues().get(SlackProcesses.CONTINUE_STEP + ".html")))
|
||||
.contains("<a href=\"https://slack.com/oauth/v2/authorize?", ">Continue to Slack</a>", "This link expires in 10 minutes.");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The Preferences action answers with the address and nothing else: the UI
|
||||
** follows a message that is only an https address, and shows any other.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void thePreferencesActionAnswersWithTheAddressAlone() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = configured())
|
||||
{
|
||||
env.asOwner("alice").setPermissions(Set.of(SlackPermissions.CONNECT, SlackPermissions.INSTALL));
|
||||
for(String name : List.of(SlackProcesses.CONNECT, SlackProcesses.INSTALL))
|
||||
{
|
||||
String message = new UserPreferencesAction().runAction("slack", name, Map.of()).getMessage();
|
||||
assertThat(message).as(name).startsWith("https://slack.com/oauth/v2/authorize?").doesNotContainAnyWhitespaces();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** After Slack, the browser lands on the Slack section of the host's
|
||||
** Preferences page, which is under /app, with a fixed outcome code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theCallbackReturnsToTheSlackPreferencesPage()
|
||||
{
|
||||
assertThat(SlackOAuthRouteProvider.RETURN_PATH).isEqualTo("/app/preferences?section=slack");
|
||||
assertThat(SlackOAuthRouteProvider.returnUrl("https://example.test/", null)).isEqualTo("https://example.test/app/preferences?section=slack&result=connected");
|
||||
assertThat(SlackOAuthRouteProvider.returnUrl("https://example.test", "authorization_denied"))
|
||||
.isEqualTo("https://example.test/app/preferences?section=slack&error=authorization_denied");
|
||||
assertThat(SlackOAuthRouteProvider.returnUrl(null, "connect_failed")).isEqualTo("/app/preferences?section=slack&error=connect_failed");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An environment that lacks the public URL, the Slack app secrets or both
|
||||
** says so, says which, and names the missing variables, never a value.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void anEnvironmentWithoutSlackSaysWhatIsMissing() throws Exception
|
||||
{
|
||||
Function<String, String> noSecrets = name -> null;
|
||||
Function<String, String> onlyClientId = name -> "SLACK_CLIENT_ID".equals(name) ? CLIENT_ID_CANARY : null;
|
||||
Function<String, String> allSecrets = name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "fake-application-secret";
|
||||
|
||||
assertThat(refusal(null, allSecrets)).isEqualTo("Slack is not set up in this environment: the public URL is not configured.");
|
||||
assertThat(refusal("https://example.test", noSecrets)).isEqualTo("Slack is not set up in this environment: "
|
||||
+ "the Slack app secrets are not configured (missing SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_TOKEN_KEY).");
|
||||
assertThat(refusal(null, onlyClientId)).isEqualTo("Slack is not set up in this environment: "
|
||||
+ "the public URL and the Slack app secrets are not configured (missing SLACK_CLIENT_SECRET, SLACK_TOKEN_KEY).")
|
||||
.doesNotContain(CLIENT_ID_CANARY);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Start a connection every way a person can, in an environment built with
|
||||
** the given public URL and secrets, and return the one refusal they all give.
|
||||
** A user-facing exception is what the UI API shows as written.
|
||||
*******************************************************************************/
|
||||
private static String refusal(String publicBaseUrl, Function<String, String> secrets) throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl(publicBaseUrl).withSecretResolver(secrets)))
|
||||
{
|
||||
env.asOwner("alice").setPermissions(Set.of(SlackPermissions.CONNECT));
|
||||
String expected = SlackConnect.notSetUp(env.config());
|
||||
assertThat(expected).isNotNull();
|
||||
assertThatThrownBy(() -> SlackConnect.startUrl("alice", "user")).isInstanceOf(QUserFacingException.class).hasMessage(expected);
|
||||
assertThatThrownBy(() -> new UserPreferencesAction().runAction("slack", SlackProcesses.CONNECT, Map.of()))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessage(expected);
|
||||
assertThatThrownBy(() -> new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.CONNECT), new RunBackendStepOutput()))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessage(expected);
|
||||
return (expected);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A configured environment has nothing to refuse.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void aConfiguredEnvironmentIsNotRefused() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = configured())
|
||||
{
|
||||
assertThat(SlackConnect.notSetUp(env.config())).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,18 +25,16 @@ import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.api.SlackApi;
|
||||
import com.kingsrook.qbits.slack.delivery.SlackDeliveryDestination;
|
||||
import com.kingsrook.qbits.slack.delivery.SlackDispatcher;
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryAttachment; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryDestinationContext; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryRequest; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.delivery.standin.DeliveryResult; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecurity;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplates;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.StorageAction;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryAttachment;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryDestinationContext;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryDestinations;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryDestinationsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryRequest;
|
||||
import com.kingsrook.qqq.backend.core.delivery.DeliveryResult;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
@@ -128,25 +126,6 @@ class SlackDeliveryDestinationTest
|
||||
assertThat(context.getDestination().getMaxAttachmentBytes()).isEqualTo(env.config().getMaxAttachmentBytes());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The producer registers Slack once in the core delivery registry, and core
|
||||
** loads this implementation from it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registeredWithCoreDelivery() throws Exception
|
||||
{
|
||||
var registry = DeliveryDestinationsMetaData.of(env.instance());
|
||||
assertThat(registry).isNotNull();
|
||||
SlackDeliveryDestination.register(env.instance(), env.config());
|
||||
var registered = registry.getDestinations().stream().filter(d -> SlackDeliveryDestination.NAME.equals(d.getName())).toList();
|
||||
assertThat(registered).singleElement().satisfies(d ->
|
||||
{
|
||||
assertThat(d.getPermissionName()).isEqualTo("slack.send");
|
||||
assertThat(d.getMaxAttachmentBytes()).isEqualTo(env.config().getMaxAttachmentBytes());
|
||||
});
|
||||
assertThat(DeliveryDestinations.implementation(registered.getFirst())).isInstanceOf(SlackDeliveryDestination.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** DM and channel use the delivery template and preserve the idempotency key.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -98,12 +98,12 @@ class SlackOAuthRouteTest
|
||||
URI uri = URI.create("http://127.0.0.1:" + app.port() + env.config().getOauthCallbackPath() + "?code=code&state=" + URLEncoder.encode(signed, StandardCharsets.UTF_8));
|
||||
var unsigned = client.send(HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(unsigned.statusCode()).isEqualTo(302);
|
||||
assertThat(unsigned.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/app/preferences?section=slack&error=sign_in_required");
|
||||
assertThat(unsigned.headers().firstValue("location").orElseThrow()).endsWith("&error=sign_in_required");
|
||||
env.slack().script("oauth.v2.access", FakeSlackServer.Reply.of(200,
|
||||
"{\"ok\":true,\"team\":{\"id\":\"T1\"},\"authed_user\":{\"id\":\"UA\",\"access_token\":\"xoxp-route-token\"}}"));
|
||||
var request = HttpRequest.newBuilder(uri).header("Cookie", "sessionUUID=" + session.getUuid()).GET().build();
|
||||
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/app/preferences?section=slack&result=connected");
|
||||
assertThat(response.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/preferences?section=slack&result=connected");
|
||||
assertThat(response.headers().map().toString()).doesNotContain("xoxp-route-token", "fake-secret");
|
||||
assertThat(client.send(request, HttpResponse.BodyHandlers.ofString()).headers().firstValue("location").orElseThrow()).endsWith("&error=connect_failed");
|
||||
}
|
||||
|
||||
@@ -1,139 +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;
|
||||
|
||||
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,36 +22,33 @@ 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 structured outcomes. The scheduled dispatch process delivers them.
|
||||
** as user-facing exceptions. The scheduled dispatch process delivers them.
|
||||
*******************************************************************************/
|
||||
class SlackProviderTest
|
||||
{
|
||||
@@ -108,8 +105,7 @@ class SlackProviderTest
|
||||
{
|
||||
env.asOwner("userA").withPermissions("slack.message.body");
|
||||
SendMessageOutput output = new SendMessageAction().execute(input(new Party().withAddress("me")));
|
||||
assertThat(output.getStatus()).isEqualTo(SendMessageStatus.QUEUED);
|
||||
assertThat(output.getMessageId()).isNotNull();
|
||||
assertThat(output).isNotNull();
|
||||
|
||||
List<QRecord> rows = SlackH2Support.query(SlackTables.MESSAGE);
|
||||
assertThat(rows).hasSize(1);
|
||||
@@ -140,91 +136,33 @@ class SlackProviderTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refusals use RC.7 status and reasons; partially accepted recipients retain IDs.
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRefusalsAreStructured() throws Exception
|
||||
void testRefusalsAreUserFacingExceptions() throws Exception
|
||||
{
|
||||
env.asOwner("userC");
|
||||
assertRefused(input(new Party().withAddress("me")), "Connect Slack");
|
||||
assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me"))))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessageContaining("Connect Slack");
|
||||
|
||||
env.asOwner("userA");
|
||||
env.slack().members("C2", "UOTHER");
|
||||
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();
|
||||
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");
|
||||
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,8 +45,6 @@ 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,7 +33,6 @@ 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;
|
||||
|
||||
@@ -63,58 +62,6 @@ 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.
|
||||
*******************************************************************************/
|
||||
@@ -219,7 +166,7 @@ class SlackReadinessTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Uninitialized readiness maps to UNKNOWN health without network access.
|
||||
** Uninitialized readiness maps to degraded health without network access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notReadyHealth() throws Exception
|
||||
@@ -227,7 +174,7 @@ class SlackReadinessTest
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
assertThat(SlackReadiness.stored(env.instance()).ready()).isFalse();
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("UNKNOWN");
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DEGRADED");
|
||||
assertThat(env.slack().requests()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +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;
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,14 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackHelp;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplates;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -153,23 +153,37 @@ class SlackTablesGuardrailTest
|
||||
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);
|
||||
assertThat(SlackHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
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());
|
||||
List<QHelpContent> help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(SlackHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
// Hosts without the help table still register metadata; core safely skips sync.
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
assertThat(instance.getApp(SlackHelp.APP_NAME).getHelpContent()).containsKey(SlackHelp.SLOT);
|
||||
assertThat(SlackHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null,
|
||||
"help names only real tables");
|
||||
assertThat(SlackHelp.entries().get("table:slack_channel;slot:help").path("content").asText()).contains("Check channels");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Help a host put in the slot first is never replaced by the bundle.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsKept() throws Exception
|
||||
{
|
||||
QInstance instance = SlackH2Support.setup(c ->
|
||||
{
|
||||
});
|
||||
QHelpContent host = new QHelpContent().withContentAsMarkdown("Host help for channels");
|
||||
instance.getTable(SlackTables.CHANNEL).getHelpContent().put(SlackHelp.SLOT, new ArrayList<>(List.of(host)));
|
||||
instance.getApp(SlackHelp.APP_NAME).getHelpContent().put(SlackHelp.SLOT, new ArrayList<>(List.of(host)));
|
||||
SlackHelp.apply(instance);
|
||||
assertThat(instance.getTable(SlackTables.CHANNEL).getHelpContent().get(SlackHelp.SLOT)).containsExactly(host);
|
||||
assertThat(instance.getApp(SlackHelp.APP_NAME).getHelpContent().get(SlackHelp.SLOT)).containsExactly(host);
|
||||
assertThat(instance.getTable(SlackTables.MESSAGE).getHelpContent().get(SlackHelp.SLOT).get(0).getContent()).contains("How rows get created");
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user