Compare commits
41
Commits
v0.1.0-RC.2
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312e95cc17
|
||
|
|
80bea0f931
|
||
|
|
2508c6b93c
|
||
|
|
65f256351c | ||
|
|
cd3205e249
|
||
|
|
26aac94b52
|
||
|
|
7804c6aed7
|
||
|
|
e1f362b22c
|
||
|
|
bf57535353 | ||
|
|
48a2387ac6
|
||
|
|
da103703aa
|
||
|
|
6d4309d5a7
|
||
|
|
d74e6a1a71
|
||
|
|
c4312ea7a1
|
||
|
|
a5bae34659
|
||
|
|
0a03bfb501
|
||
|
|
33ba265215 | ||
|
|
7a0fd3762a | ||
|
|
2682c2fa94 | ||
|
|
42aaf3c9c8 | ||
|
|
a40eade67b | ||
|
|
f0b70d7da7 | ||
|
|
4dcabdffed | ||
|
|
de09fc27e9 | ||
|
|
fb4ae6db07
|
||
|
|
2f7025ef28 | ||
|
|
33d993f414
|
||
|
|
87a2218ef6 | ||
|
|
56b6f48a7b
|
||
|
|
80083044a8 | ||
|
|
1811bbf211
|
||
|
|
070e2c95d9 | ||
|
|
7f71fc0566
|
||
|
|
91b24cb986 | ||
|
|
36def54b8b
|
||
|
|
c28210353e | ||
|
|
37524048ba
|
||
|
|
1ddbf470de
|
||
|
|
c1064601da | ||
|
|
d8063498a2
|
||
|
|
4bc330d96b
|
No files matched your search
+10
-61
@@ -1,65 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [ /^v\d+\.\d+\.\d+$/ ]
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release-no-integration
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -6,6 +6,20 @@ 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.
|
||||
- "Check channels" action on Registered Channels; readiness now writes the channel's Slack name, private, bot is member, archived, purpose and checked time back to the row.
|
||||
- User group `handle` is resolved from `usergroups.list` during readiness (optional `usergroups:read` scope) and used in the mention (`<!subteam^ID|@handle>`).
|
||||
- `SlackTablesGuardrailTest`: every table is used at runtime, seeded or listed as starting empty, and has help.
|
||||
- Design (`docs/DESIGN.md`) and the Slack app manifest (`docs/slack-app-manifest.yaml`).
|
||||
- Repository scaffold per the QRun qbit repository standard: `SlackQBitProducer` and `SlackQBitConfig`.
|
||||
@@ -12,7 +12,7 @@ Design: [docs/DESIGN.md](docs/DESIGN.md).
|
||||
## Requirements
|
||||
|
||||
- Java 21
|
||||
- QQQ 4.1 (currently built against 4.1.0-RC.6)
|
||||
- QQQ 4.1 (currently built against 4.1.0-RC.7)
|
||||
- A Slack app created from [docs/slack-app-manifest.yaml](docs/slack-app-manifest.yaml)
|
||||
|
||||
## Install
|
||||
@@ -31,12 +31,43 @@ See [docs/DESIGN.md](docs/DESIGN.md), sections 3 to 14. The tables are prefixed
|
||||
permissions `slack.`. Secrets are environment-variable names only: `SLACK_CLIENT_ID`,
|
||||
`SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET` and `SLACK_TOKEN_KEY`.
|
||||
|
||||
## Setup and starter data
|
||||
|
||||
No Liquibase changelog or host include is needed: the qbit seeds its own data at startup. The three
|
||||
built-in templates (`notification`, `digest`, `delivery`) are inserted by `SlackTemplates.seed()`
|
||||
when the host starts and on each readiness run. It is insert-only by name, so edits survive.
|
||||
|
||||
Everything else is host or runtime data, and every table explains itself when empty (help bundle
|
||||
`help/qbit-slack.yaml`):
|
||||
|
||||
1. An administrator runs "Install for my organization" (Connections). This creates the workspace.
|
||||
2. Each user runs "Connect Slack" so messages go out as them.
|
||||
3. Register the channels and user groups processes post to (Registered Channels, User Groups), then
|
||||
run "Check channels". It records the Slack name, private, bot is member, archived, purpose and
|
||||
checked time. Readiness refreshes them on a schedule.
|
||||
4. Review the built-in Templates.
|
||||
|
||||
Messages, Attempts and Interactions are logs and start empty.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
Send with core `SendMessageAction` and the provider name `slack`, or deliver output to the `slack`
|
||||
destination. Don't import this qbit's internal packages; `com.kingsrook.qbits.slack.api` is the only
|
||||
public surface.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [help/qbit-slack.yaml](src/main/resources/help/qbit-slack.yaml)
|
||||
with QQQ's help bundle registry. Hosts with help content enabled sync it at startup;
|
||||
registration is harmless without a `helpContent` table. Live references use the host's current labels.
|
||||
|
||||
To override text, register a separate bundle with `layer: HOST` and the same entry keys
|
||||
(and roles) using `HelpBundles.register(qInstance, "help/your-host.yaml")`.
|
||||
HOST entries take precedence over QBIT entries; user edits are preserved by core sync.
|
||||
|
||||
`SlackTablesGuardrailTest.everyTableHasEmptyStateHelp` runs `HelpBundleAssertions` over
|
||||
Slack's produced metadata to check coverage, keys, summaries and live references during `verify`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Upgrade notes
|
||||
|
||||
## Unreleased
|
||||
|
||||
- No schema change and no Liquibase include. Hosts need do nothing.
|
||||
- Registered channels now show real status. After upgrading, run "Check channels" on Registered
|
||||
Channels, or wait for the next readiness run, to fill `slackChannelName`, `isPrivate`,
|
||||
`botIsMember`, `isArchived`, `description` and `checkedAt`. `slackChannelName` is now set from
|
||||
Slack, so the label in the outbox follows the channel's current Slack name.
|
||||
- A user group's `handle` is filled during readiness when the Slack app has the optional
|
||||
`usergroups:read` scope; mentions then read `<!subteam^ID|@handle>`.
|
||||
+34
-11
@@ -121,7 +121,12 @@ 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.
|
||||
- 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).
|
||||
- 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.
|
||||
@@ -132,8 +137,11 @@ 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 Preferences page with a result notice.
|
||||
- Errors redirect with a plain message, never token text.
|
||||
- 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.
|
||||
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.
|
||||
@@ -230,14 +238,15 @@ have changed since the message was queued. Then it decrypts **only** the token o
|
||||
There is no addressing by email, and no raw Slack ids without a workspace. `MultiParty` gives one outbox row
|
||||
per recipient. An unknown or unauthorized target is REFUSED with a reason, never an exception.
|
||||
|
||||
- **Capabilities** (core #1194): `TEMPLATES`, `IDEMPOTENCY`, `CATEGORIES`, `LINKED_RECORDS`, `ASYNC_DELIVERY`
|
||||
and `MESSAGE_LOOKUP`.
|
||||
- **Output:** `QUEUED` with the id, `DUPLICATE` with the earlier id, or `REFUSED` or `SUPPRESSED` with reasons.
|
||||
- **Capabilities** (core #1194): `TEMPLATES`, `IDEMPOTENCY`, `CATEGORIES`, `LINKED_RECORDS`, `ASYNC_DELIVERY`.
|
||||
- **Output:** `QUEUED` with the id, `DUPLICATE` with the earlier id, or `REFUSED` with reasons.
|
||||
For multiple recipients, `messageId` contains the accepted IDs (a list when more than one), and
|
||||
idempotency keys are scoped by recipient. A partial refusal returns `REFUSED` with accepted IDs;
|
||||
otherwise the status is `QUEUED` if any row is new, or `DUPLICATE` if all already existed.
|
||||
- **Content:** `subject` becomes the bold first line and the notification text. Content items are mrkdwn,
|
||||
escaped. A Block Kit JSON body needs `slack.send.blocks`. A template wins.
|
||||
- **Dependency:** QQQ 4.1.0-RC.6 has the plain provider SPI. #1194's rich fields arrive in RC.7.
|
||||
- Until then the bridge maps the RC.6 fields, and `SlackApi` carries the rest.
|
||||
- The gap is marked `STAND-IN(core=#1194, removeBy=RC.8)`.
|
||||
- **Dependency:** QQQ 4.1.0-RC.7. The bridge maps template name, variables, idempotency key,
|
||||
category, linked record and sender name directly to the outbox. Locale remains advisory and is ignored.
|
||||
|
||||
### 4.3 Public API `com.kingsrook.qbits.slack.api` (mirrors `EmailApi`)
|
||||
|
||||
@@ -429,6 +438,11 @@ user with `users.lookupByEmail`, but only when the IdP marked the session email
|
||||
results, and a user sees their own in Preferences.
|
||||
- **Checks run:** every `readinessIntervalMinutes` (15), on the **Check readiness** action, and from
|
||||
`SlackApi.readiness*`. Messages never include token values or exception text.
|
||||
- **Not configured is not a failure.** With no Slack app secrets at all, the `app` check is NOT_CONFIGURED and
|
||||
health is UNKNOWN (HTTP 200) with `configured: false`. Before the first readiness run, health is also UNKNOWN.
|
||||
Only a configured-but-broken app (some secrets missing, or an invalid token key) is DOWN. Slack is optional, so an
|
||||
unset Slack integration must never fail a readiness probe; "not configured" shows in the readiness and
|
||||
central-health views only.
|
||||
- **Health:** when `qqq-middleware-health` is present, the qbit registers `HealthCheckMetaData` `slack`.
|
||||
- It reports the stored deployment-level readiness (`app` and `outbox`), without calling Slack on each
|
||||
probe.
|
||||
@@ -529,7 +543,16 @@ qbit-slack's own Preferences section (core #1162) has:
|
||||
|
||||
- the list of Connections, read-only;
|
||||
- the actions **Connect Slack**, **Disconnect** and **Send me a test message**;
|
||||
- for tenant admins, **Install for my organization**.
|
||||
- with `slack.workspace.install`, **Install for my organization**.
|
||||
|
||||
The section requires `slack.connect`, appears in the user menu, and rejects PATCH requests.
|
||||
Actions are filtered per session; test messages also require `slack.send`. RC.7 has no action-level
|
||||
permission property, so the section returns a filtered metadata view without mutating its shared
|
||||
registrations. Connect and Install return their authorize URL 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.
|
||||
|
||||
Without qbit-alerts, callers use `SendMessageAction` (`slack`, `me` or `user:<id>`) directly.
|
||||
|
||||
@@ -694,7 +717,7 @@ DDL is `docs/ddl/slack-postgres.sql`. kof22 applies it as a Liquibase changeset.
|
||||
|
||||
**Phase 1f: app, home and preferences section.**
|
||||
|
||||
- Built on core #1162 (RC.7). Until then the work is behind a stand-in.
|
||||
- Built directly on the core #1162 preferences SPI (RC.7), with a class-availability guard for registration.
|
||||
- Screenshots: home, a message and a connection, light and dark, at 390 and 1440 px.
|
||||
|
||||
**Phase 1g: qbit-alerts routing (section 10).** A separate qbit-alerts PR.
|
||||
|
||||
@@ -22,6 +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>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -34,7 +35,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.1.1</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -77,7 +78,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.19</version>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -120,7 +121,35 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-slack.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-slack.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -213,7 +242,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData;
|
||||
import com.kingsrook.qbits.slack.delivery.SlackAlerts;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
@@ -31,6 +34,7 @@ import com.kingsrook.qbits.slack.security.SlackSecurity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
|
||||
@@ -42,13 +46,41 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-slack";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private SlackQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered properties resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = SlackQBitProducer.class.getClassLoader().getResourceAsStream("qbit-slack.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing qbit-slack.properties; build with Maven to supply the artifact version"));
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw (new IllegalStateException("Missing or unfiltered version in qbit-slack.properties; build with Maven"));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not read artifact version from qbit-slack.properties", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
@@ -147,10 +179,13 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
|
||||
|
||||
output.addSelfToInstance(qInstance);
|
||||
com.kingsrook.qbits.slack.delivery.SlackDeliveryDestination.register();
|
||||
com.kingsrook.qbits.slack.delivery.SlackDeliveryDestination.register(qInstance, getQBitConfig());
|
||||
qInstance.addApp(com.kingsrook.qbits.slack.app.SlackApp.app(qInstance.getTables()));
|
||||
com.kingsrook.qbits.slack.app.SlackApp.widgets().forEach(qInstance::addWidget);
|
||||
com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter.register(qInstance);
|
||||
if(isPreferencesAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.slack.preferences.SlackPreferencesProvider.register(qInstance);
|
||||
}
|
||||
com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance);
|
||||
com.kingsrook.qbits.slack.interactivity.SlackActions.registerBuiltIns(qInstance, getQBitConfig());
|
||||
try
|
||||
@@ -162,11 +197,31 @@ public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
|
||||
{
|
||||
// Optional health module is absent in this host.
|
||||
}
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-slack.yaml");
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard only class availability; registration errors must still surface.
|
||||
*******************************************************************************/
|
||||
private static boolean isPreferencesAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData", false, SlackQBitProducer.class.getClassLoader());
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The config.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -107,7 +107,7 @@ public final class SlackHomeView
|
||||
}
|
||||
}
|
||||
return (table(List.of(column("Workspace", "workspace", "html"), column("Check", "check", "text"), column("Status", "status", "text"), column("Fix", "fix", "text")),
|
||||
rows, "No workspaces installed."));
|
||||
rows, "No workspaces installed. An administrator runs Install for my organization on Connections."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -21,21 +21,23 @@
|
||||
package com.kingsrook.qbits.slack.bridge;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendRequest;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendResult;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackService;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageStatus;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -43,22 +45,15 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** producer under the config's bridgeProviderName), so any process sends with
|
||||
** core SendMessageAction and depends only on core (docs/DESIGN.md 4.2).
|
||||
**
|
||||
** How the QQQ 4.1.0-RC.6 input maps:
|
||||
** - to: a Party's address is a Slack address (me, user:<id>,
|
||||
** channel:<workspace>/<channel>, channel:<name>, #<name>,
|
||||
** group:<name>); a MultiParty gives one outbox row per recipient;
|
||||
** - subject: the bold first line of the message;
|
||||
** - contentList: the bodies, one per line, as plain text (the service escapes it);
|
||||
** - the owner is the current session's user, so an unconnected user, another
|
||||
** user's channel or another tenant's workspace is refused.
|
||||
**
|
||||
** A refusal is a QUserFacingException with the reason. RC.6 has no REFUSED or
|
||||
** QUEUED status on its output and no idempotency field, so the status is only
|
||||
** logged here.
|
||||
** RC.7 rich input maps directly to the outbox request. The owner remains the
|
||||
** session user. For multiple recipients, messageId is a list of accepted IDs,
|
||||
** and idempotency keys are scoped by recipient. Any refusal yields REFUSED
|
||||
** with reasons; accepted recipients remain queued. Otherwise the aggregate is
|
||||
** QUEUED if any row is new, or DUPLICATE if all already existed.
|
||||
*******************************************************************************/
|
||||
public class SlackOutboxMessagingProvider implements MessagingProviderInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SlackOutboxMessagingProvider.class);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -74,49 +69,75 @@ public class SlackOutboxMessagingProvider implements MessagingProviderInterface
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue one outbox row per recipient. Anything refused is reported after the
|
||||
** others were queued, as one QUserFacingException.
|
||||
** Declare only capabilities implemented by the outbox bridge.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<MessagingProviderCapability> getCapabilities()
|
||||
{
|
||||
return (Set.of(MessagingProviderCapability.TEMPLATES, MessagingProviderCapability.IDEMPOTENCY,
|
||||
MessagingProviderCapability.CATEGORIES, MessagingProviderCapability.LINKED_RECORDS, MessagingProviderCapability.ASYNC_DELIVERY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue one outbox row per recipient and return the core outcome explicitly.
|
||||
** Locale is advisory in core and has no Slack outbox equivalent.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SendMessageOutput sendMessage(SendMessageInput input) throws QException
|
||||
{
|
||||
if(input.getAttachmentList() != null && !input.getAttachmentList().isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("Slack attachments aren't supported yet"));
|
||||
return (refused("Slack attachments aren't supported yet"));
|
||||
}
|
||||
|
||||
List<String> addresses = new ArrayList<>();
|
||||
collect(input.getTo(), addresses);
|
||||
addresses = addresses.stream().distinct().toList();
|
||||
if(addresses.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("A Slack recipient is required"));
|
||||
return (refused("A Slack recipient is required"));
|
||||
}
|
||||
|
||||
String text = compose(input);
|
||||
|
||||
List<String> refusals = new ArrayList<>();
|
||||
ArrayList<Integer> ids = new ArrayList<>();
|
||||
boolean queued = false;
|
||||
for(String address : addresses)
|
||||
{
|
||||
SlackSendResult result = SlackService.enqueue(new SlackSendRequest().withTo(address).withText(text));
|
||||
String key = input.getIdempotencyKey();
|
||||
if(key != null && addresses.size() > 1)
|
||||
{
|
||||
key += ":" + UUID.nameUUIDFromBytes(address.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
SlackSendResult result = SlackService.enqueue(new SlackSendRequest().withTo(address).withText(text)
|
||||
.withTemplateName(input.getTemplateName()).withVariables(input.getVariables()).withIdempotencyKey(key)
|
||||
.withCategory(input.getCategory()).withSenderName(input.getSenderName())
|
||||
.withLinkedRecord(input.getLinkedTableName(), input.getLinkedRecordId() == null ? null : input.getLinkedRecordId().toString()));
|
||||
if(result.status() == SlackSendResult.Status.REFUSED)
|
||||
{
|
||||
refusals.addAll(result.reasons());
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6's SendMessageOutput //
|
||||
// has no status or message id fields, so the outcome is only logged here //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
LOG.debug("Slack message handed to the outbox", logPair("status", result.status().name()), logPair("messageId", result.messageId()));
|
||||
ids.add(result.messageId());
|
||||
queued |= result.status() == SlackSendResult.Status.QUEUED;
|
||||
}
|
||||
}
|
||||
return (new SendMessageOutput().withMessageId(ids.isEmpty() ? null : ids.size() == 1 ? ids.get(0) : ids)
|
||||
.withStatus(!refusals.isEmpty() ? SendMessageStatus.REFUSED : queued ? SendMessageStatus.QUEUED : SendMessageStatus.DUPLICATE)
|
||||
.withReasons(refusals));
|
||||
}
|
||||
|
||||
if(!refusals.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException(String.join("; ", refusals)));
|
||||
}
|
||||
return (new SendMessageOutput());
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refusal before any outbox row is created.
|
||||
*******************************************************************************/
|
||||
private static SendMessageOutput refused(String reason)
|
||||
{
|
||||
return (new SendMessageOutput().withStatus(SendMessageStatus.REFUSED).withReasons(List.of(reason)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,15 +26,9 @@ 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;
|
||||
@@ -45,45 +39,72 @@ 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-callable Slack output destination until core's delivery SPI is released.
|
||||
** Targets are workspaceId and where (me or a channel address from SlackApi).
|
||||
** 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).
|
||||
*******************************************************************************/
|
||||
public class SlackDeliveryDestination implements DeliveryDestinationInterface
|
||||
{
|
||||
public static final String NAME = "slack";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the policy hosts must apply before invoking this destination.
|
||||
** Describe the policy core applies before invoking this destination, using
|
||||
** the Slack config of the current instance.
|
||||
*******************************************************************************/
|
||||
public QDeliveryDestinationMetaData metadata()
|
||||
{
|
||||
return (new QDeliveryDestinationMetaData().withName("slack").withLabel("Slack")
|
||||
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")
|
||||
.withPermissionName("slack.send").withCodeReference(new QCodeReference(SlackDeliveryDestination.class))
|
||||
.withMaxClassification(DataClassification.CONFIDENTIAL)
|
||||
.withAboveMaxClassification(QDeliveryDestinationMetaData.AboveMaxClassification.REFUSE)
|
||||
.withMaxAttachmentBytes(SlackRuntime.config().getMaxAttachmentBytes()).withAllowBearerLinks(false));
|
||||
.withMaxAttachmentBytes(config.getMaxAttachmentBytes()).withAllowBearerLinks(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Detect the real SPI without registering an incompatible temporary instance.
|
||||
** Register with the core delivery registry; production may run repeatedly,
|
||||
** and a destination already registered under this name is kept.
|
||||
*******************************************************************************/
|
||||
public static void register()
|
||||
public static void register(QInstance instance, SlackQBitConfig config)
|
||||
{
|
||||
try
|
||||
DeliveryDestinationsMetaData registry = DeliveryDestinationsMetaData.ofOrWithNew(instance);
|
||||
if(registry.getDestinations().stream().noneMatch(destination -> NAME.equals(destination.getName())))
|
||||
{
|
||||
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.
|
||||
registry.withDestination(metadata(config));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Offer only active connections and membership-checked targets of this user.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
* 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
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* 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
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/*
|
||||
* 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
@@ -1,396 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -374,13 +374,13 @@ public final class SlackTables
|
||||
.withField(field("label", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true))
|
||||
.withField(field("slackChannelId", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("slackChannelName", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("isPrivate", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("botIsMember", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("isArchived", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("slackChannelName", QFieldType.STRING).withMaxLength(500).withIsEditable(false))
|
||||
.withField(field("isPrivate", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false))
|
||||
.withField(field("botIsMember", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false))
|
||||
.withField(field("isArchived", QFieldType.BOOLEAN).withDefaultValue(false).withIsEditable(false))
|
||||
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(field("description", QFieldType.TEXT))
|
||||
.withField(field("checkedAt", QFieldType.DATE_TIME));
|
||||
.withField(field("description", QFieldType.TEXT).withIsEditable(false))
|
||||
.withField(field("checkedAt", QFieldType.DATE_TIME).withIsEditable(false));
|
||||
table.withRecordLabelFormatAndFields("%s", "name");
|
||||
table.withUniqueKey(new UniqueKey("workspaceId", "name"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "name", "label", "workspaceId", "enabled"));
|
||||
@@ -422,7 +422,7 @@ public final class SlackTables
|
||||
QTableMetaData table = base(config, TEMPLATE, "Templates")
|
||||
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("label", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("description", QFieldType.TEXT))
|
||||
.withField(field("description", QFieldType.TEXT).withIsEditable(false))
|
||||
.withField(field("category", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(field("tenantKey", QFieldType.STRING).withMaxLength(500))
|
||||
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
@@ -35,6 +36,7 @@ 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;
|
||||
@@ -59,8 +61,10 @@ 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)
|
||||
public static String startUrl(String userId, String mode) throws QException
|
||||
{
|
||||
QSession session = session();
|
||||
if(!session.getUser().getIdReference().equals(userId) || !session.hasPermission("slack.connect")
|
||||
@@ -69,6 +73,11 @@ 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()))
|
||||
@@ -274,14 +283,52 @@ 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)
|
||||
if(config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank())
|
||||
{
|
||||
throw (new IllegalArgumentException("Slack public URL is required"));
|
||||
throw (new IllegalArgumentException("Slack is not set up in this environment: the public URL is not configured."));
|
||||
}
|
||||
return (config.getPublicBaseUrl().replaceAll("/+$", "") + config.getOauthCallbackPath());
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ 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;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -137,7 +140,18 @@ public class SlackOAuthRouteProvider implements QJavalinRouteProviderInterface
|
||||
QContext.init(previous);
|
||||
}
|
||||
context.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer");
|
||||
context.redirect(SlackRuntime.config(instance).getPublicBaseUrl().replaceAll("/+$", "")
|
||||
+ "/preferences?section=slack" + (error == null ? "&result=connected" : "&error=" + error));
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ 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;
|
||||
|
||||
@@ -72,7 +75,7 @@ public final class SlackOAuthState
|
||||
throw (new IllegalArgumentException("Slack connect unavailable"));
|
||||
}
|
||||
String nonce = UUID.randomUUID().toString();
|
||||
long expiry = now + 600;
|
||||
long expiry = now + LIFETIME_SECONDS;
|
||||
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,15 +27,16 @@ 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. 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.
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
public final class SlackPayloadGuard
|
||||
{
|
||||
@@ -88,7 +89,10 @@ public final class SlackPayloadGuard
|
||||
{
|
||||
throw (new IllegalArgumentException("Unknown classified field"));
|
||||
}
|
||||
classification = DataClassification.max(classification, AiDataClassifications.ofField(table, field));
|
||||
if(ClassifiedValues.isClassified(table, field))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (classification != null && classification.isAtLeast(DataClassification.PII));
|
||||
}
|
||||
@@ -123,7 +127,7 @@ public final class SlackPayloadGuard
|
||||
{
|
||||
throw (new RestrictedVariableException(declaration.name()));
|
||||
}
|
||||
safe.put(entry.getKey(), "changed:v1");
|
||||
safe.put(entry.getKey(), ClassifiedValueTokens.changed());
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -145,7 +149,7 @@ public final class SlackPayloadGuard
|
||||
{
|
||||
throw (new RestrictedVariableException(declaration.name()));
|
||||
}
|
||||
safe.put(declaration.name(), "changed:v1");
|
||||
safe.put(declaration.name(), ClassifiedValueTokens.changed());
|
||||
}
|
||||
}
|
||||
return (safe);
|
||||
@@ -168,7 +172,7 @@ public final class SlackPayloadGuard
|
||||
{
|
||||
throw (new IllegalArgumentException("Restricted template variable"));
|
||||
}
|
||||
return ("changed:v1");
|
||||
return (ClassifiedValueTokens.changed());
|
||||
}
|
||||
return (sanitize(classified.value(), tokenize));
|
||||
}
|
||||
|
||||
@@ -74,10 +74,25 @@ 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)
|
||||
public static String connect(boolean install) throws QException
|
||||
{
|
||||
return (SlackConnect.startUrl(owner(), install ? "user+bot" : "user"));
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack.preferences;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.8)
|
||||
** RC.6 has no preferences SPI. Keep its anticipated registration boundary here,
|
||||
** and fail explicitly on an incompatible future SPI rather than silently losing
|
||||
** the section. Connections actions remain available on RC.6.
|
||||
*******************************************************************************/
|
||||
public final class SlackPreferencesAdapter
|
||||
{
|
||||
public static final String CORE_TYPE = "com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SlackPreferencesAdapter()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No linkage to missing core classes in the RC.6 artifact.
|
||||
*******************************************************************************/
|
||||
public static boolean register(QInstance instance) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName(CORE_TYPE);
|
||||
Object metadata = type.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance);
|
||||
type.getMethod("withSection", String.class, String.class, QCodeReference.class)
|
||||
.invoke(metadata, "slack", "Slack", new QCodeReference(SlackPreferences.class));
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new QException("Update SlackPreferencesAdapter for the installed core preferences SPI.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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,6 +30,7 @@ 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;
|
||||
@@ -42,6 +43,9 @@ 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;
|
||||
@@ -63,9 +67,13 @@ public final class SlackProcesses
|
||||
public static final String DISCONNECT = "slack_disconnect";
|
||||
public static final String UNINSTALL = "slack_uninstall";
|
||||
public static final String READINESS = "slack_check_readiness";
|
||||
public static final String CHECK_CHANNELS = "slack_check_channels";
|
||||
public static final String MAINTAIN = "slack_readiness";
|
||||
public static final String DISPATCH = "slack_dispatch";
|
||||
|
||||
/** The one screen of Connect Slack and Install: its HTML is the process value "continue.html". */
|
||||
public static final String CONTINUE_STEP = "continue";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -101,38 +109,71 @@ public final class SlackProcesses
|
||||
}
|
||||
return (List.of(dispatch, readiness, connectAction(CONNECT, "Connect Slack"), connectAction(INSTALL, "Install for my organization"),
|
||||
action(TEST_MESSAGE, "Send me a test message", SlackTables.CONNECTION).withMinInputRecords(1).withMaxInputRecords(1), action(DISCONNECT, "Disconnect", SlackTables.CONNECTION),
|
||||
action(UNINSTALL, "Uninstall", SlackTables.WORKSPACE), action(READINESS, "Check readiness", SlackTables.WORKSPACE)));
|
||||
action(UNINSTALL, "Uninstall", SlackTables.WORKSPACE), action(READINESS, "Check readiness", SlackTables.WORKSPACE),
|
||||
action(CHECK_CHANNELS, "Check channels", SlackTables.CHANNEL)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 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"))));
|
||||
.withStep(new QFrontendStepMetaData().withName(CONTINUE_STEP).withLabel("Continue to Slack")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HTML))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return the authorize URL through the standard process UI API.
|
||||
** Put the way to Slack on the process screen through the standard UI API.
|
||||
*******************************************************************************/
|
||||
public static class ConnectStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No remote mutation happens until the user approves at Slack.
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output)
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.addValue("authorizeUrl", com.kingsrook.qbits.slack.preferences.SlackPreferences.connect(INSTALL.equals(input.getProcessName())));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@@ -232,6 +273,11 @@ public final class SlackProcesses
|
||||
{
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
if(CHECK_CHANNELS.equals(input.getProcessName()))
|
||||
{
|
||||
checkChannel(row, output);
|
||||
continue;
|
||||
}
|
||||
switch(input.getProcessName())
|
||||
{
|
||||
case TEST_MESSAGE ->
|
||||
@@ -260,6 +306,34 @@ public final class SlackProcesses
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check one registered channel. A channel that needs attention is a
|
||||
** warning, and a problem with one row (for example its workspace token)
|
||||
** is an error for that row only, so it cannot stop the rest.
|
||||
*******************************************************************************/
|
||||
private void checkChannel(QRecord row, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(!com.kingsrook.qqq.backend.core.context.QContext.getQSession().hasPermission("slack.admin"))
|
||||
{
|
||||
throw (new QException("Slack channel check is not permitted"));
|
||||
}
|
||||
String name = row.getValueString("name");
|
||||
try
|
||||
{
|
||||
switch(SlackReadiness.channel(row.getValueInteger("id")))
|
||||
{
|
||||
case USABLE -> lines.add(new ProcessSummaryLine(Status.OK, 1, "Channel " + name + " is ready"));
|
||||
case NEEDS_ATTENTION -> lines.add(new ProcessSummaryLine(Status.WARNING, 1, "Channel " + name + " needs attention: it is archived, not found, or the bot is not a member"));
|
||||
default -> lines.add(new ProcessSummaryLine(Status.WARNING, 1, "Slack could not confirm channel " + name + "; try again later"));
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.ERROR, 1, "Channel " + name + " could not be checked: its workspace is unavailable"));
|
||||
}
|
||||
output.getRecords().add(row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Result summary.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -64,9 +64,29 @@ public class SlackHealth implements HealthIndicator
|
||||
public HealthCheckResult check(QInstance instance)
|
||||
{
|
||||
var readiness = SlackReadiness.stored(instance);
|
||||
boolean appFailed = readiness.checks().stream().anyMatch(c -> "app".equals(c.name()) && "ERROR".equals(c.status()));
|
||||
HealthStatus status = appFailed ? HealthStatus.DOWN : readiness.ready() ? HealthStatus.UP : HealthStatus.DEGRADED;
|
||||
String appStatus = readiness.checks().stream().filter(c -> "app".equals(c.name())).map(c -> c.status()).findFirst().orElse("UNKNOWN");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Slack is optional. Not configured (no secrets at all) or not yet checked //
|
||||
// is UNKNOWN, which never fails a readiness probe (HTTP 200). Only a //
|
||||
// configured-but-broken app (partial or invalid secrets) is DOWN. //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
if(SlackReadiness.NOT_CONFIGURED.equals(appStatus))
|
||||
{
|
||||
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN)
|
||||
.withDetail("configured", false)
|
||||
.withDetail("message", "Slack is not configured")
|
||||
.withDetail("checks", readiness.checks()));
|
||||
}
|
||||
if("UNKNOWN".equals(appStatus))
|
||||
{
|
||||
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN)
|
||||
.withDetail("message", "Slack readiness has not run yet")
|
||||
.withDetail("checks", readiness.checks()));
|
||||
}
|
||||
HealthStatus status = "ERROR".equals(appStatus) ? HealthStatus.DOWN : readiness.ready() ? HealthStatus.UP : HealthStatus.DEGRADED;
|
||||
return (new HealthCheckResult().withStatus(status)
|
||||
.withDetail("configured", true)
|
||||
.withDetail("checks", readiness.checks()));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
*******************************************************************************/
|
||||
public final class SlackReadiness
|
||||
{
|
||||
public static final String NOT_CONFIGURED = "NOT_CONFIGURED";
|
||||
|
||||
private static final Map<QInstance, SlackApiReadiness> STORED = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private static final Map<QInstance, Map<Integer, SlackApiReadiness>> WORKSPACES = java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>());
|
||||
@@ -87,6 +89,17 @@ public final class SlackReadiness
|
||||
app = false;
|
||||
}
|
||||
List<SlackApiReadinessCheck> checks = new ArrayList<>();
|
||||
if(!app && !anySecretPresent(config))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// no SLACK_* secret at all: Slack is simply not set up in this //
|
||||
// deployment. That is a state, not a failure; health reports UNKNOWN. //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
checks.add(new SlackApiReadinessCheck("app", NOT_CONFIGURED, "Slack is not configured: no Slack app secrets are set"));
|
||||
SlackApiReadiness result = new SlackApiReadiness(false, checks);
|
||||
STORED.put(instance, result);
|
||||
return (result);
|
||||
}
|
||||
checks.add(check("app", app, "Application configuration"));
|
||||
boolean outbox = true;
|
||||
if(config != null && QContext.getQInstance() == instance)
|
||||
@@ -112,6 +125,33 @@ public final class SlackReadiness
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** True when at least one of the app's secret names resolves to a value.
|
||||
** Presence only; values are never retained.
|
||||
*******************************************************************************/
|
||||
static boolean anySecretPresent(SlackQBitConfig config)
|
||||
{
|
||||
if(config == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
for(String name : java.util.Arrays.asList(config.getClientIdEnvName(), config.getClientSecretEnvName(), config.getSigningSecretEnvName(), config.getTokenKeyEnvName()))
|
||||
{
|
||||
try
|
||||
{
|
||||
SlackSecrets.get(config, name);
|
||||
return (true);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// absent or not allowed; keep looking
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Last scheduled deployment result, without network or database work.
|
||||
*******************************************************************************/
|
||||
@@ -130,6 +170,7 @@ public final class SlackReadiness
|
||||
List<SlackApiReadinessCheck> checks = new ArrayList<>();
|
||||
boolean good = false;
|
||||
boolean channels = true;
|
||||
boolean channelsUnknown = false;
|
||||
boolean optionalScopes = false;
|
||||
try
|
||||
{
|
||||
@@ -143,11 +184,23 @@ public final class SlackReadiness
|
||||
{
|
||||
if(Boolean.TRUE.equals(channel.getValueBoolean("enabled")))
|
||||
{
|
||||
var info = SlackRuntime.client(config).conversationsInfo(token, channel.getValueString("slackChannelId"));
|
||||
var json = info.getJson() == null ? null : info.getJson().optJSONObject("channel");
|
||||
channels &= info.isOk() && json != null && !json.optBoolean("is_archived", true) && json.optBoolean("is_member", false);
|
||||
ChannelCheck outcome = checkChannel(config, token, channel);
|
||||
if(outcome == ChannelCheck.UNKNOWN)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Slack did not give a definitive answer (rate limit, outage, auth) //
|
||||
// so stop checking this workspace and leave the rows as they were //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
channelsUnknown = true;
|
||||
break;
|
||||
}
|
||||
channels &= outcome == ChannelCheck.USABLE;
|
||||
}
|
||||
}
|
||||
if(response.getScopes() != null && List.of(response.getScopes().split(",")).contains("usergroups:read"))
|
||||
{
|
||||
syncUserGroups(config, token, id);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -156,7 +209,8 @@ public final class SlackReadiness
|
||||
}
|
||||
checks.add(new SlackApiReadinessCheck("workspace:" + id, good ? optionalScopes ? "READY" : "WARN" : "ERROR",
|
||||
good && !optionalScopes ? "Workspace ready; optional scopes are missing" : "Workspace identity and required scopes"));
|
||||
checks.add(check("registeredChannels", channels, "Enabled channels and bot membership"));
|
||||
checks.add(channelsUnknown && channels ? new SlackApiReadinessCheck("registeredChannels", "WARN", "Slack could not confirm the channels; they will be checked again")
|
||||
: check("registeredChannels", channels, "Enabled channels and bot membership"));
|
||||
SlackApiReadiness result = result(checks);
|
||||
WORKSPACES.computeIfAbsent(QContext.getQInstance(), key -> new java.util.concurrent.ConcurrentHashMap<>()).put(id, result);
|
||||
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("readinessStatus", result.ready() ? "OK" : "FAIL")
|
||||
@@ -164,6 +218,97 @@ public final class SlackReadiness
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** What a channel check found. UNKNOWN means Slack gave no definitive answer
|
||||
** (rate limit, timeout, outage, invalid auth), so nothing was written.
|
||||
*******************************************************************************/
|
||||
public enum ChannelCheck
|
||||
{
|
||||
USABLE, NEEDS_ATTENTION, UNKNOWN
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check one registered channel against Slack and, on a definitive answer,
|
||||
** write what Slack says back to its row (name, privacy, bot membership,
|
||||
** archived, purpose, checked time). Definitive: the channel came back, or
|
||||
** Slack said channel_not_found or not_in_channel (recorded as bot not a
|
||||
** member). Anything else leaves the row alone.
|
||||
*******************************************************************************/
|
||||
static ChannelCheck checkChannel(SlackQBitConfig config, String token, QRecord channel) throws QException
|
||||
{
|
||||
var info = SlackRuntime.client(config).conversationsInfo(token, channel.getValueString("slackChannelId"));
|
||||
var json = info.getJson() == null ? null : info.getJson().optJSONObject("channel");
|
||||
boolean ok = info.isOk() && json != null;
|
||||
boolean gone = !info.isOk() && info.getRetryAfterSeconds() == null && info.getHttpStatus() > 0 && info.getHttpStatus() < 500
|
||||
&& ("channel_not_found".equals(info.getError()) || "not_in_channel".equals(info.getError()));
|
||||
if(!ok && !gone)
|
||||
{
|
||||
return (ChannelCheck.UNKNOWN);
|
||||
}
|
||||
QRecord update = new QRecord().withValue("id", channel.getValueInteger("id")).withValue("checkedAt", SlackRuntime.clock().instant());
|
||||
if(ok)
|
||||
{
|
||||
String purpose = json.optJSONObject("purpose") == null ? "" : json.optJSONObject("purpose").optString("value", "");
|
||||
String topic = json.optJSONObject("topic") == null ? "" : json.optJSONObject("topic").optString("value", "");
|
||||
update.withValue("isPrivate", json.optBoolean("is_private", false)).withValue("isArchived", json.optBoolean("is_archived", false))
|
||||
.withValue("botIsMember", json.optBoolean("is_member", false)).withValue("description", purpose.isBlank() ? topic.isBlank() ? null : topic : purpose);
|
||||
if(!json.optString("name", "").isBlank())
|
||||
{
|
||||
update.withValue("slackChannelName", json.getString("name"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
update.withValue("botIsMember", false);
|
||||
}
|
||||
SlackStore.update(SlackTables.CHANNEL, update, null);
|
||||
return (ok && !json.optBoolean("is_archived", false) && json.optBoolean("is_member", false) ? ChannelCheck.USABLE : ChannelCheck.NEEDS_ATTENTION);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Check channels" action: check the given registered channel with its
|
||||
** workspace bot token, as the signed-in administrator. A token problem is
|
||||
** thrown so the action can report it for this row only.
|
||||
*******************************************************************************/
|
||||
public static ChannelCheck channel(Integer id) throws QException
|
||||
{
|
||||
QRecord channel = SlackConnect.visible(SlackTables.CHANNEL, id);
|
||||
SlackQBitConfig config = SlackRuntime.config();
|
||||
return (checkChannel(config, SlackTokens.botToken(config, channel.getValueInteger("workspaceId")), channel));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve each enabled registered user group's @handle from usergroups.list
|
||||
** and keep it on the row, so mentions show the handle. Best effort: a
|
||||
** failure leaves the rows as they were.
|
||||
*******************************************************************************/
|
||||
static void syncUserGroups(SlackQBitConfig config, String token, Integer workspaceId) throws QException
|
||||
{
|
||||
var response = SlackRuntime.client(config).usergroupsList(token);
|
||||
var array = response.isOk() && response.getJson() != null ? response.getJson().optJSONArray("usergroups") : null;
|
||||
if(array == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Map<String, String> handles = new java.util.HashMap<>();
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
var group = array.optJSONObject(i);
|
||||
if(group != null && !group.optString("id", "").isBlank())
|
||||
{
|
||||
handles.put(group.getString("id"), group.optString("handle", ""));
|
||||
}
|
||||
}
|
||||
for(QRecord row : SlackStore.queryAsCurrent(SlackTables.USER_GROUP, SlackStore.equal("workspaceId", workspaceId)))
|
||||
{
|
||||
String handle = handles.get(row.getValueString("slackUsergroupId"));
|
||||
if(handle != null && !handle.isBlank() && !handle.equals(row.getValueString("handle")))
|
||||
{
|
||||
SlackStore.update(SlackTables.USER_GROUP, new QRecord().withValue("id", row.getValueInteger("id")).withValue("handle", handle), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify only a visible connection's own token and Slack identity.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -405,7 +405,8 @@ public class SlackAuthorizer
|
||||
{
|
||||
return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't linked to a Slack user group yet"));
|
||||
}
|
||||
mention = "<!subteam^" + row.getValueString("slackUsergroupId") + "> ";
|
||||
String handle = row.getValueString("handle");
|
||||
mention = "<!subteam^" + row.getValueString("slackUsergroupId") + (handle != null && handle.matches("@?[A-Za-z0-9._-]+") ? "|@" + handle.replaceFirst("^@", "") : "") + "> ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -293,6 +293,17 @@ public class SlackClient
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** usergroups.list: the workspace's user groups (id, handle), for resolving
|
||||
** a registered group's @handle. Needs the optional usergroups:read scope.
|
||||
*******************************************************************************/
|
||||
public SlackResponse usergroupsList(String token)
|
||||
{
|
||||
return (get(token, "usergroups.list", new LinkedHashMap<>()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One page of users.conversations: the channels the token's user is in.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
bundle: qbit-slack
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:slack;slot:help
|
||||
summary: "Connect your Slack account, manage message destinations and review delivery results."
|
||||
content: |
|
||||
Start with **{{label:slack_install}}** in **{{label:slack_connection}}**, then use **{{label:slack_connect}}** to link your own account. Register the channels and user groups you need, and review **{{label:slack_template}}** before sending.
|
||||
|
||||
Use **{{label:slack_message}}** to follow delivery progress and **{{label:slack_message_attempt}}** to investigate failures. Your home page shows your connections and recent deliveries. Administrators can also review workspace readiness, queued messages and recent failures.
|
||||
- key: table:slack_workspace;slot:help
|
||||
summary: "Slack workspaces installed for your organization, with connection health and readiness."
|
||||
content: |
|
||||
Use **{{label:slack_install}}** from **{{label:slack_connection}}** to create a workspace by approving access in Slack. You do not create workspace records by hand. Review **{{label:slack_check_readiness}}** results to identify missing access or channel setup. Administrators can use **{{label:slack_uninstall}}** to remove the installation.
|
||||
- key: table:slack_workspace;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_workspace;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_workspace;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_workspace;field:teamId
|
||||
summary: "The identifier Slack assigns to the workspace."
|
||||
- key: table:slack_workspace;field:teamName
|
||||
summary: "The workspace name returned by Slack."
|
||||
- key: table:slack_workspace;field:enterpriseId
|
||||
summary: "The Slack enterprise identifier associated with the installation."
|
||||
- key: table:slack_workspace;field:appId
|
||||
summary: "The identifier of the installed Slack app."
|
||||
- key: table:slack_workspace;field:tenantKey
|
||||
summary: "The organization that owns this configuration."
|
||||
- key: table:slack_workspace;field:installedByUserId
|
||||
summary: "The account that installed the Slack app."
|
||||
- key: table:slack_workspace;field:installedAt
|
||||
summary: "When the Slack app was installed."
|
||||
- key: table:slack_workspace;field:botUserId
|
||||
summary: "The Slack user identifier for the installed bot."
|
||||
- key: table:slack_workspace;field:botScopes
|
||||
summary: "The permissions granted to the Slack bot."
|
||||
- key: table:slack_workspace;field:botTokenFingerprint
|
||||
summary: "A credential identifier used to compare bot credentials without revealing them."
|
||||
- key: table:slack_workspace;field:status
|
||||
summary: "The current state of the Slack installation."
|
||||
- key: table:slack_workspace;field:readinessStatus
|
||||
summary: "The result of the latest workspace readiness check."
|
||||
- key: table:slack_workspace;field:readinessCheckedAt
|
||||
summary: "When workspace readiness was last checked."
|
||||
- key: table:slack_workspace;field:readinessSummary
|
||||
summary: "A short explanation of the latest workspace readiness result."
|
||||
- key: table:slack_workspace;field:health
|
||||
summary: "Whether recent delivery failures have made the workspace unhealthy."
|
||||
- key: table:slack_workspace;field:consecutiveFailures
|
||||
summary: "The number of consecutive delivery failures for the workspace."
|
||||
- key: table:slack_workspace;field:ratePerMinute
|
||||
summary: "The configured maximum number of message sends per minute for this workspace."
|
||||
- key: table:slack_workspace;field:botToken
|
||||
summary: "The protected credential used by the Slack bot."
|
||||
- key: table:slack_workspace;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_workspace;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_workspace;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_workspace;slot:help
|
||||
summary: "Available workspace records you can reference."
|
||||
- key: table:slack_connection;slot:help
|
||||
summary: "Links between your account and Slack, used to send messages with your Slack identity."
|
||||
content: |
|
||||
Use **{{label:slack_connect}}** and approve access in Slack to create your connection. Your organization must install the app first. Select your connection and use **{{label:slack_test_message}}** to check delivery. Use **{{label:slack_disconnect}}** when you want to revoke the link. Administrators can review connections for other users.
|
||||
- key: table:slack_connection;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_connection;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_connection;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_connection;field:userId
|
||||
summary: "The application account associated with this Slack activity."
|
||||
- key: table:slack_connection;field:workspaceId
|
||||
summary: "The Slack workspace associated with this record."
|
||||
- key: table:slack_connection;field:slackUserId
|
||||
summary: "The user identifier supplied by Slack."
|
||||
- key: table:slack_connection;field:slackUserName
|
||||
summary: "The connected user name returned by Slack."
|
||||
- key: table:slack_connection;field:userTokenFingerprint
|
||||
summary: "A credential identifier used to compare user credentials without revealing them."
|
||||
- key: table:slack_connection;field:userScopes
|
||||
summary: "The permissions granted by the connected Slack user."
|
||||
- key: table:slack_connection;field:status
|
||||
summary: "The current state of the personal Slack connection."
|
||||
- key: table:slack_connection;field:connectedAt
|
||||
summary: "When the Slack account was connected."
|
||||
- key: table:slack_connection;field:lastVerifiedAt
|
||||
summary: "When the connection was last verified."
|
||||
- key: table:slack_connection;field:revokedAt
|
||||
summary: "When access through this connection was revoked."
|
||||
- key: table:slack_connection;field:lastError
|
||||
summary: "The latest recorded error explanation."
|
||||
- key: table:slack_connection;field:userToken
|
||||
summary: "The protected credential for the connected Slack account."
|
||||
- key: table:slack_connection;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_connection;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_connection;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_connection;slot:help
|
||||
summary: "Available connection records you can reference."
|
||||
- key: table:slack_channel;slot:help
|
||||
summary: "Named Slack channels available as message destinations, with their latest access checks."
|
||||
content: |
|
||||
Add a registered channel by choosing a workspace, entering a short **Name** and supplying its **Slack channel id**. Invite the app to the channel in Slack, then run **{{label:slack_check_channels}}** on the selected registered channels. Review the retrieved name, membership and archive status before using the channel for delivery. Disable channels you no longer want senders to use.
|
||||
- key: table:slack_channel;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_channel;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_channel;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_channel;field:name
|
||||
summary: "The short name used to select this configuration."
|
||||
- key: table:slack_channel;field:label
|
||||
summary: "The display name for this configuration."
|
||||
- key: table:slack_channel;field:workspaceId
|
||||
summary: "The Slack workspace associated with this record."
|
||||
- key: table:slack_channel;field:slackChannelId
|
||||
summary: "The destination channel identifier supplied by Slack."
|
||||
- key: table:slack_channel;field:slackChannelName
|
||||
summary: "The channel name retrieved from Slack."
|
||||
- key: table:slack_channel;field:isPrivate
|
||||
summary: "Whether Slack reports this channel as private."
|
||||
- key: table:slack_channel;field:botIsMember
|
||||
summary: "Whether the installed bot belongs to this channel."
|
||||
- key: table:slack_channel;field:isArchived
|
||||
summary: "Whether Slack reports this channel as archived."
|
||||
- key: table:slack_channel;field:enabled
|
||||
summary: "Whether this configuration is available for use."
|
||||
- key: table:slack_channel;field:description
|
||||
summary: "The channel purpose retrieved from Slack."
|
||||
- key: table:slack_channel;field:checkedAt
|
||||
summary: "When the channel details were last checked against Slack."
|
||||
- key: table:slack_channel;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_channel;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_channel;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_channel;slot:help
|
||||
summary: "Available registered channel records you can reference."
|
||||
- key: table:slack_user_group;slot:help
|
||||
summary: "Named Slack user groups that messages can mention in a specified channel."
|
||||
content: |
|
||||
Add a group by choosing a workspace and entering a short **Name**, the **Slack usergroup id** and the destination **Channel id**. Messages to the group mention it in that channel. **{{label:slack_check_readiness}}** can retrieve the group handle when the installation has permission to read user groups.
|
||||
- key: table:slack_user_group;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_user_group;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_user_group;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_user_group;field:name
|
||||
summary: "The short name used to select this configuration."
|
||||
- key: table:slack_user_group;field:label
|
||||
summary: "The display name for this configuration."
|
||||
- key: table:slack_user_group;field:workspaceId
|
||||
summary: "The Slack workspace associated with this record."
|
||||
- key: table:slack_user_group;field:slackUsergroupId
|
||||
summary: "The identifier Slack assigns to the user group."
|
||||
- key: table:slack_user_group;field:handle
|
||||
summary: "The Slack handle used when mentioning this group."
|
||||
- key: table:slack_user_group;field:channelId
|
||||
summary: "The Slack channel where messages mention this group."
|
||||
- key: table:slack_user_group;field:enabled
|
||||
summary: "Whether this configuration is available for use."
|
||||
- key: table:slack_user_group;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_user_group;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_user_group;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_user_group;slot:help
|
||||
summary: "Available user group records you can reference."
|
||||
- key: table:slack_template;slot:help
|
||||
summary: "Reusable Slack message text and layouts with declared input values."
|
||||
content: |
|
||||
Review the built-in notification, digest and delivery templates, or add your own. Give each template a unique **Name** that senders can select. Declare the values it accepts in **Variables schema** and use them in **Text** or **Blocks template**. Existing templates are preserved when the built-ins are added again. Do not include personal or health information.
|
||||
- key: table:slack_template;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_template;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_template;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_template;field:name
|
||||
summary: "The short name used to select this configuration."
|
||||
- key: table:slack_template;field:label
|
||||
summary: "The display name for this configuration."
|
||||
- key: table:slack_template;field:description
|
||||
summary: "An explanation of the intended use of this template."
|
||||
- key: table:slack_template;field:category
|
||||
summary: "The category assigned to this message or template."
|
||||
- key: table:slack_template;field:enabled
|
||||
summary: "Whether this configuration is available for use."
|
||||
- key: table:slack_template;field:tenantKey
|
||||
summary: "The organization that owns this configuration."
|
||||
- key: table:slack_template;field:text
|
||||
summary: "The message text sent to Slack."
|
||||
- key: table:slack_template;field:blocksTemplate
|
||||
summary: "The reusable Slack message layout, with placeholders for declared values."
|
||||
- key: table:slack_template;field:variablesSchema
|
||||
summary: "The definitions and restrictions for values accepted by this template."
|
||||
content: |
|
||||
Declare every value your template accepts before using it in the message text or layout. These definitions control which inputs are valid and how they may be used. Keep personal and health information out of template values, and supply links as URLs rather than arbitrary text.
|
||||
- key: table:slack_template;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_template;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_template;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_template;slot:help
|
||||
summary: "Available template records you can reference."
|
||||
- key: table:slack_message;slot:help
|
||||
summary: "Queued Slack messages and their delivery history, including retries and failures."
|
||||
content: |
|
||||
Open a message to review its destination, sending identity and delivery status. Messages are created automatically when a process, alert, delivery or test requests a send. You do not add or edit this history by hand. Review **{{label:slack_message_attempt}}** for delivery errors.
|
||||
- key: table:slack_message;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_message;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_message;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_message;field:ownerUserId
|
||||
summary: "The application account responsible for sending this message."
|
||||
- key: table:slack_message;field:workspaceId
|
||||
summary: "The Slack workspace associated with this record."
|
||||
- key: table:slack_message;field:connectionId
|
||||
summary: "The personal Slack connection used for this message."
|
||||
- key: table:slack_message;field:postAs
|
||||
summary: "Whether this message is sent as the bot or a connected user."
|
||||
- key: table:slack_message;field:operation
|
||||
summary: "Whether the request posts a message, updates one or uploads a file."
|
||||
- key: table:slack_message;field:targetType
|
||||
summary: "The kind of recipient or destination for this message."
|
||||
- key: table:slack_message;field:targetRef
|
||||
summary: "The requested recipient or destination reference."
|
||||
- key: table:slack_message;field:slackChannelId
|
||||
summary: "The destination channel identifier supplied by Slack."
|
||||
- key: table:slack_message;field:channelLabel
|
||||
summary: "The display name recorded for the destination channel."
|
||||
- key: table:slack_message;field:status
|
||||
summary: "The current delivery state, from queuing through completion or failure."
|
||||
- key: table:slack_message;field:category
|
||||
summary: "The category assigned to this message or template."
|
||||
- key: table:slack_message;field:templateName
|
||||
summary: "The name of the template used to prepare the message."
|
||||
- key: table:slack_message;field:text
|
||||
summary: "The message text sent to Slack."
|
||||
- key: table:slack_message;field:blocksJson
|
||||
summary: "The prepared Slack message layout sent with this request."
|
||||
- key: table:slack_message;field:variablesSnapshot
|
||||
summary: "The template values captured when the message was prepared."
|
||||
- key: table:slack_message;field:idempotencyKey
|
||||
summary: "A unique request reference that prevents the same send from being queued twice."
|
||||
content: |
|
||||
Use this reference to identify repeated requests for the same send. The message queue requires it to be unique, so retrying a request with the same reference does not create another queued message. This prevents duplicate requests; review the delivery status separately to confirm whether Slack received the message.
|
||||
- key: table:slack_message;field:parentMessageId
|
||||
summary: "The earlier message this reply waits for before it can be delivered."
|
||||
- key: table:slack_message;field:threadTs
|
||||
summary: "The Slack identifier of the conversation thread."
|
||||
- key: table:slack_message;field:targetMessageId
|
||||
summary: "The existing message that this request updates."
|
||||
- key: table:slack_message;field:attachmentStorageRef
|
||||
summary: "The stored file reference used to retrieve the attachment."
|
||||
- key: table:slack_message;field:attachmentStorageTable
|
||||
summary: "The storage collection holding the attachment."
|
||||
- key: table:slack_message;field:attachmentName
|
||||
summary: "The name of the attached file."
|
||||
- key: table:slack_message;field:attachmentBytes
|
||||
summary: "The size of the attachment in bytes."
|
||||
- key: table:slack_message;field:slackFileId
|
||||
summary: "The identifier Slack returned for the uploaded file."
|
||||
- key: table:slack_message;field:fileReconcileRequired
|
||||
summary: "Whether an uncertain file upload needs reconciliation before another attempt."
|
||||
content: |
|
||||
A file request may reach Slack even when its response is lost. This flag indicates that the sender must check what happened before trying the upload again. Review the file identifier and attempt history when investigating the message, so an uncertain result is not mistaken for a confirmed failure.
|
||||
- key: table:slack_message;field:slackTs
|
||||
summary: "The identifier Slack returned for the posted message."
|
||||
- key: table:slack_message;field:permalink
|
||||
summary: "A direct link to the message in Slack."
|
||||
- key: table:slack_message;field:attemptCount
|
||||
summary: "The number of delivery attempts recorded for this message."
|
||||
- key: table:slack_message;field:nextAttemptDate
|
||||
summary: "When this message next becomes eligible for a delivery attempt."
|
||||
- key: table:slack_message;field:leaseExpiresDate
|
||||
summary: "When the current delivery worker loses its exclusive claim on the message."
|
||||
- key: table:slack_message;field:lastErrorCode
|
||||
summary: "The code for the latest delivery error."
|
||||
- key: table:slack_message;field:lastError
|
||||
summary: "The latest recorded error explanation."
|
||||
- key: table:slack_message;field:sentDate
|
||||
summary: "When delivery was recorded as successful."
|
||||
- key: table:slack_message;field:deadLetterDate
|
||||
summary: "When automatic delivery attempts stopped for this message."
|
||||
- key: table:slack_message;field:linkedTableName
|
||||
summary: "The source collection associated with this message."
|
||||
- key: table:slack_message;field:linkedRecordId
|
||||
summary: "The source record associated with this message."
|
||||
- key: table:slack_message;field:senderName
|
||||
summary: "The sender name recorded with this message."
|
||||
- key: table:slack_message;field:deliveryLogId
|
||||
summary: "The related delivery log reference."
|
||||
- key: table:slack_message;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_message;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_message;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_message;slot:help
|
||||
summary: "Available message records you can reference."
|
||||
- key: table:slack_message_attempt;slot:help
|
||||
summary: "Individual attempts to deliver a Slack message, with outcomes, errors and timing."
|
||||
content: |
|
||||
Use these records to investigate a message that is waiting to retry or has failed. Each delivery attempt records its outcome, timing and any error returned. Follow **Message id** to the related message for its destination and overall status. Attempt records are created automatically and cannot be edited.
|
||||
- key: table:slack_message_attempt;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_message_attempt;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_message_attempt;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_message_attempt;field:messageId
|
||||
summary: "The message associated with this record."
|
||||
- key: table:slack_message_attempt;field:attemptNumber
|
||||
summary: "The sequence number of this delivery attempt."
|
||||
- key: table:slack_message_attempt;field:outcome
|
||||
summary: "The result of this individual delivery attempt."
|
||||
- key: table:slack_message_attempt;field:errorCode
|
||||
summary: "The error code recorded for this delivery attempt."
|
||||
- key: table:slack_message_attempt;field:httpStatus
|
||||
summary: "The response status returned by the Slack request."
|
||||
- key: table:slack_message_attempt;field:retryAfterSeconds
|
||||
summary: "The number of seconds Slack asked the sender to wait before retrying."
|
||||
- key: table:slack_message_attempt;field:postedAs
|
||||
summary: "Whether the attempt used the bot or a connected user."
|
||||
- key: table:slack_message_attempt;field:claimedBy
|
||||
summary: "The delivery worker that claimed this attempt."
|
||||
- key: table:slack_message_attempt;field:startDate
|
||||
summary: "When this delivery attempt started."
|
||||
- key: table:slack_message_attempt;field:endDate
|
||||
summary: "When this delivery attempt ended."
|
||||
- key: table:slack_message_attempt;field:error
|
||||
summary: "The recorded explanation of the failure."
|
||||
- key: table:slack_message_attempt;section:identity
|
||||
summary: "The identifying details and main settings for this record."
|
||||
- key: table:slack_message_attempt;section:data
|
||||
summary: "Additional configuration and operational details for this record."
|
||||
- key: table:slack_message_attempt;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: possibleValueSource:slack_message_attempt;slot:help
|
||||
summary: "Available message attempt records you can reference."
|
||||
- key: table:slack_workspace;section:connections
|
||||
summary: "Personal Slack connections for this workspace."
|
||||
- key: table:slack_workspace;section:channels
|
||||
summary: "Registered channels belonging to this workspace."
|
||||
- key: table:slack_workspace;section:groups
|
||||
summary: "User groups registered for this workspace."
|
||||
- key: table:slack_message;section:attempts
|
||||
summary: "Delivery attempts recorded for this message."
|
||||
- key: possibleValueSource:slack_workspace_status;slot:help
|
||||
summary: "The available states of a Slack installation."
|
||||
- key: possibleValueSource:slack_connection_status;slot:help
|
||||
summary: "The available states of a personal Slack connection."
|
||||
- key: possibleValueSource:slack_readiness_status;slot:help
|
||||
summary: "The possible results of a workspace readiness check."
|
||||
- key: possibleValueSource:slack_health_status;slot:help
|
||||
summary: "The available workspace delivery health states."
|
||||
- key: possibleValueSource:slack_message_status;slot:help
|
||||
summary: "The available message delivery states."
|
||||
- key: possibleValueSource:slack_message_operation;slot:help
|
||||
summary: "The supported message operations: post, update and file upload."
|
||||
- key: possibleValueSource:slack_target_type;slot:help
|
||||
summary: "The supported Slack recipient and destination types."
|
||||
- key: possibleValueSource:slack_post_as;slot:help
|
||||
summary: "The available sending identities: bot or connected user."
|
||||
- key: possibleValueSource:slack_attempt_outcome;slot:help
|
||||
summary: "The possible results of a single delivery attempt."
|
||||
- key: widget:slackHome;slot:label
|
||||
summary: "Your Slack connections and their current status."
|
||||
- key: widget:slackHomeDeliveries;slot:label
|
||||
summary: "Your Slack delivery totals for the last 24 hours."
|
||||
- key: widget:slackHomeRecent;slot:label
|
||||
summary: "Your most recent Slack deliveries."
|
||||
- key: widget:slackHomeReadiness;slot:label
|
||||
summary: "Workspace readiness results available to administrators."
|
||||
- key: widget:slackHomeOutbox;slot:label
|
||||
summary: "Totals for queued Slack messages and other delivery states."
|
||||
- key: widget:slackHomeFailures;slot:label
|
||||
summary: "Recent Slack delivery failures available to administrators."
|
||||
- key: widget:slack_connection_children;slot:label
|
||||
summary: "Personal Slack connections for this workspace."
|
||||
- key: widget:slack_channel_children;slot:label
|
||||
summary: "Channels registered for this workspace."
|
||||
- key: widget:slack_user_group_children;slot:label
|
||||
summary: "User groups registered for this workspace."
|
||||
- key: widget:slack_message_children;slot:label
|
||||
summary: "Messages associated with this workspace."
|
||||
- key: widget:slack_message_attempt_children;slot:label
|
||||
summary: "Delivery attempts for this message."
|
||||
- key: process:slack_connect;slot:help
|
||||
summary: "Link your account to Slack so you can send messages with your Slack identity."
|
||||
content: |
|
||||
Use **{{label:slack_connect}}** in **{{label:slack_connection}}** to obtain a Slack authorization link. Open the link and approve the requested access in Slack. Your workspace must already have the app installed. After approval, review your connection and use **{{label:slack_test_message}}** to check it.
|
||||
- key: process:slack_install;slot:help
|
||||
summary: "Install the Slack app for your organization through Slack authorization."
|
||||
content: |
|
||||
Use **{{label:slack_install}}** in **{{label:slack_connection}}** to obtain the installation link. Open it in Slack and approve access for the intended workspace. Approval creates the workspace record and stores the protected bot credential. You need permission to install the app; individual users then connect their own accounts.
|
||||
- key: process:slack_test_message;slot:help
|
||||
summary: "Queue a test message to your own Slack account using the selected connection."
|
||||
content: |
|
||||
Select one of your own records in **{{label:slack_connection}}**, then choose **{{label:slack_test_message}}**. Review the selection before continuing. You need permission to send messages. The action queues a test for delivery; check **{{label:slack_message}}** and its attempts if the test does not arrive in Slack.
|
||||
- key: process:slack_disconnect;slot:help
|
||||
summary: "Revoke the selected personal Slack connections."
|
||||
content: |
|
||||
Select the connections you want to revoke and choose **{{label:slack_disconnect}}**. Review the selected records before continuing. You can disconnect your own account with connection permission; administrators can manage other connections they can access. Use **{{label:slack_connect}}** again if you later need to restore your personal connection.
|
||||
- key: process:slack_uninstall;slot:help
|
||||
summary: "Remove the Slack app installation for the selected workspaces."
|
||||
content: |
|
||||
As an administrator, select the workspace installations you want to remove and choose **{{label:slack_uninstall}}**. Review the selected records before continuing. This action removes the workspace installation, rather than just disconnecting your own account. Use **{{label:slack_disconnect}}** in **{{label:slack_connection}}** when you only want to revoke a personal connection.
|
||||
- key: process:slack_check_readiness;slot:help
|
||||
summary: "Check workspace setup, permissions and registered Slack destinations."
|
||||
content: |
|
||||
Select a workspace and choose **{{label:slack_check_readiness}}** to refresh the readiness information. Review the selection before continuing. You need administrator access to run this check. Inspect the resulting status and summary, and review registered channels when access or membership needs attention before relying on message delivery.
|
||||
- key: process:slack_readiness;slot:help
|
||||
summary: "Refresh Slack readiness and add any missing built-in templates."
|
||||
content: |
|
||||
This scheduled action refreshes deployment and workspace readiness and adds missing built-in templates. Your edits to existing templates are preserved. Review **Workspace readiness** on the Slack home page to see issues that need attention. Administrators can also use **{{label:slack_check_readiness}}** from **{{label:slack_workspace}}** when they need a fresh check.
|
||||
- key: process:slack_dispatch;slot:help
|
||||
summary: "Process Slack messages that are due for delivery and record the results."
|
||||
content: |
|
||||
This scheduled action picks up messages that are due for delivery and attempts to send them to Slack. Review **{{label:slack_message}}** for the current delivery state and **{{label:slack_message_attempt}}** for individual results and errors. A queued request is not proof of delivery; check its recorded result when investigating a missing message.
|
||||
- key: table:slack_interaction;slot:help
|
||||
summary: "Recorded Slack button interactions, with their processing results."
|
||||
content: |
|
||||
Review **Interactions** on a message to see who acted and whether the action completed. These records are created automatically when Slack sends button interactions. You do not add or edit this audit history. Button actions run with the clicking user's permissions.
|
||||
- key: process:slack_check_channels;slot:help
|
||||
summary: "Refresh the Slack details and access status of selected registered channels."
|
||||
content: |
|
||||
Select records in **{{label:slack_channel}}** and choose **{{label:slack_check_channels}}**. As an administrator, review the selection before continuing. The check updates the channel name, privacy, membership, archive status, purpose and check time. Review warnings for channels that need attention and retry later if Slack could not confirm their status.
|
||||
- key: possibleValueSource:slack_interaction;slot:help
|
||||
summary: "Recorded Slack interactions you can reference."
|
||||
- key: possibleValueSource:slack_interaction_outcome;slot:help
|
||||
summary: "The possible processing results for a Slack interaction."
|
||||
- key: widget:slack_interaction_children;slot:label
|
||||
summary: "Slack interactions recorded for this message."
|
||||
- key: table:slack_message;section:interactions
|
||||
summary: "Button clicks and their processing results for this message."
|
||||
- key: table:slack_interaction;field:id
|
||||
summary: "The unique identifier for this record."
|
||||
- key: table:slack_interaction;field:createDate
|
||||
summary: "When this record was created."
|
||||
- key: table:slack_interaction;field:modifyDate
|
||||
summary: "When this record was last updated."
|
||||
- key: table:slack_interaction;field:workspaceId
|
||||
summary: "The Slack workspace associated with this interaction."
|
||||
- key: table:slack_interaction;field:messageId
|
||||
summary: "The message associated with this interaction."
|
||||
- key: table:slack_interaction;field:teamId
|
||||
summary: "The workspace identifier supplied by Slack."
|
||||
- key: table:slack_interaction;field:slackUserId
|
||||
summary: "The Slack user who performed the action."
|
||||
- key: table:slack_interaction;field:userId
|
||||
summary: "The application account associated with this action."
|
||||
- key: table:slack_interaction;field:actionName
|
||||
summary: "The action requested by the Slack interaction."
|
||||
- key: table:slack_interaction;field:deliveryKey
|
||||
summary: "The unique delivery reference used to recognize repeated Slack requests."
|
||||
- key: table:slack_interaction;field:error
|
||||
summary: "The recorded explanation of a processing failure."
|
||||
- key: table:slack_interaction;field:outcome
|
||||
summary: "The processing result of this interaction."
|
||||
- key: table:slack_interaction;field:completedDate
|
||||
summary: "When processing of this interaction completed."
|
||||
- key: table:slack_interaction;section:identity
|
||||
summary: "The interaction identity, related records and processing result."
|
||||
- key: table:slack_interaction;section:data
|
||||
summary: "Additional action details and processing information."
|
||||
- key: table:slack_interaction;section:dates
|
||||
summary: "When this record was created and last updated."
|
||||
- key: process:slack_workspace.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_workspace}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_workspace.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_workspace.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_workspace.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_workspace}}**. Review the selection before continuing and check the result for any records that could not be deleted. To revoke access in Slack, use **{{label:slack_uninstall}}** before deleting the record.
|
||||
- key: process:slack_connection.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_connection}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_connection.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_connection.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_connection.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_connection}}**. Review the selection before continuing and check the result for any records that could not be deleted. To revoke access in Slack, use **{{label:slack_disconnect}}** before deleting the record.
|
||||
- key: process:slack_channel.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_channel}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_channel.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_channel.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_channel.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_channel}}**. Review the selection before continuing and check the result for any records that could not be deleted.
|
||||
- key: process:slack_channel.bulkInsert;slot:help
|
||||
summary: "Add records from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_channel.bulkInsert}}** to add records to **{{label:slack_channel}}** from a file. Map the columns to the required fields, review the proposed records and correct validation errors before continuing. Check the results after the import.
|
||||
- key: process:slack_user_group.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_user_group}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_user_group.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_user_group.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_user_group.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_user_group}}**. Review the selection before continuing and check the result for any records that could not be deleted.
|
||||
- key: process:slack_user_group.bulkInsert;slot:help
|
||||
summary: "Add records from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_user_group.bulkInsert}}** to add records to **{{label:slack_user_group}}** from a file. Map the columns to the required fields, review the proposed records and correct validation errors before continuing. Check the results after the import.
|
||||
- key: process:slack_template.bulkEdit;slot:help
|
||||
summary: "Update editable fields on selected records together."
|
||||
content: |
|
||||
Select the records you want to change in **{{label:slack_template}}**. Choose the editable fields and values, review the proposed changes, then continue. Check the result for records that could not be updated.
|
||||
- key: process:slack_template.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_template.bulkEditWithFile}}** to upload changes for existing records. Map your file columns to the matching record identifier and editable fields. Review the proposed updates before continuing, then check the result for errors.
|
||||
- key: process:slack_template.bulkDelete;slot:help
|
||||
summary: "Delete selected records from the application."
|
||||
content: |
|
||||
Select the records you intend to remove from **{{label:slack_template}}**. Review the selection before continuing and check the result for any records that could not be deleted.
|
||||
- key: process:slack_template.bulkInsert;slot:help
|
||||
summary: "Add records from an uploaded file."
|
||||
content: |
|
||||
Use **{{label:slack_template.bulkInsert}}** to add records to **{{label:slack_template}}** from a file. Map the columns to the required fields, review the proposed records and correct validation errors before continuing. Check the results after the import.
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -32,7 +32,6 @@ import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker;
|
||||
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
|
||||
import com.kingsrook.qbits.slack.preferences.SlackPreferences;
|
||||
import com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter;
|
||||
import com.kingsrook.qbits.slack.processes.SlackProcesses;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecurity;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
@@ -297,7 +296,7 @@ class SlackAppTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 skips registration; personal section exposes only permitted actions.
|
||||
** Personal section exposes only permitted actions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void preferencesAndConnect() throws Exception
|
||||
@@ -305,7 +304,6 @@ class SlackAppTest
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl("http://localhost:8099")
|
||||
.withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "demo-secret")))
|
||||
{
|
||||
assertThat(SlackPreferencesAdapter.register(env.instance())).isFalse();
|
||||
int workspace = env.workspace("T", null, "xoxb-demo", "B");
|
||||
env.connection("alice", workspace, "UA", null);
|
||||
env.connection("bob", workspace, "UB", null);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.oauth.SlackConnect;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackSendRequest;
|
||||
import com.kingsrook.qbits.slack.outbox.SlackService;
|
||||
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The registered channel's status fields and the user group's handle are
|
||||
** written from what Slack says, so the screens show real status.
|
||||
*******************************************************************************/
|
||||
class SlackChannelStatusTest
|
||||
{
|
||||
private static final String OK_AUTH = "{\"ok\":true,\"team_id\":\"T1\"}";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SlackTestEnv environment() throws Exception
|
||||
{
|
||||
return (SlackTestEnv.create(false, c -> c.withSecretResolver(n -> "SLACK_TOKEN_KEY".equals(n) ? SlackTokenCipherTest.KEY_ONE : "test")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Readiness persists name, privacy, membership, archived, purpose and time.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readinessWritesChannelStatusBack() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int channel = env.channel(workspace, "ops", "C1", null);
|
||||
assertThat(env.row(SlackTables.CHANNEL, channel).getValueInstant("checkedAt")).isNull();
|
||||
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES)));
|
||||
env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{\"name\":\"ops-alerts\",\"is_private\":true,"
|
||||
+ "\"is_archived\":false,\"is_member\":true,\"purpose\":{\"value\":\"Operations alerts\"}}}"));
|
||||
assertThat(SlackReadiness.workspace(workspace).checks()).anyMatch(c -> c.name().equals("registeredChannels") && c.status().equals("READY"));
|
||||
|
||||
QRecord row = env.row(SlackTables.CHANNEL, channel);
|
||||
assertThat(row.getValueString("slackChannelName")).isEqualTo("ops-alerts");
|
||||
assertThat(row.getValueBoolean("isPrivate")).isTrue();
|
||||
assertThat(row.getValueBoolean("botIsMember")).isTrue();
|
||||
assertThat(row.getValueBoolean("isArchived")).isFalse();
|
||||
assertThat(row.getValueString("description")).isEqualTo("Operations alerts");
|
||||
assertThat(row.getValueInstant("checkedAt")).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An archived channel, or one the bot has not joined, is recorded as such.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableChannelsAreRecorded() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int channel = env.channel(workspace, "old", "C1", "old");
|
||||
for(String flags : List.of("\"is_archived\":true,\"is_member\":true", "\"is_archived\":false,\"is_member\":false"))
|
||||
{
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES)));
|
||||
env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{" + flags + "}}"));
|
||||
SlackReadiness.workspace(workspace);
|
||||
QRecord row = env.row(SlackTables.CHANNEL, channel);
|
||||
assertThat(row.getValueBoolean("isArchived")).isEqualTo(flags.contains("\"is_archived\":true"));
|
||||
assertThat(row.getValueBoolean("botIsMember")).isEqualTo(flags.contains("\"is_member\":true"));
|
||||
assertThat(row.getValueString("slackChannelName")).as("kept when Slack sends no name").isEqualTo("old");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Check channels" action checks one selected channel, even a disabled
|
||||
** one, and a channel Slack cannot return is recorded as not joined.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void checkChannelsActionChecksOneChannel() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int channel = env.channel(workspace, "ops", "C1", null);
|
||||
env.set(SlackTables.CHANNEL, channel, "enabled", false);
|
||||
|
||||
env.slack().script("conversations.info", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"channel\":{\"name\":\"ops\",\"is_member\":true}}"));
|
||||
assertThat(SlackReadiness.channel(channel)).isEqualTo(SlackReadiness.ChannelCheck.USABLE);
|
||||
assertThat(env.row(SlackTables.CHANNEL, channel).getValueBoolean("botIsMember")).isTrue();
|
||||
|
||||
env.slack().script("conversations.info", FakeSlackServer.Reply.error("channel_not_found"));
|
||||
assertThat(SlackReadiness.channel(channel)).isEqualTo(SlackReadiness.ChannelCheck.NEEDS_ATTENTION);
|
||||
QRecord row = env.row(SlackTables.CHANNEL, channel);
|
||||
assertThat(row.getValueBoolean("botIsMember")).isFalse();
|
||||
assertThat(row.getValueInstant("checkedAt")).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Readiness resolves a user group's handle; the mention then carries it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void userGroupHandleIsResolvedAndMentioned() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int group = env.userGroup(workspace, "oncall", "S123", "C1");
|
||||
env.connection("owner", workspace, "U1", "xoxp-test");
|
||||
env.slack().members("C1", "U1", "UBOT");
|
||||
env.slack().name("C1", "ops");
|
||||
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES + ",usergroups:read")));
|
||||
env.slack().script("usergroups.list", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"usergroups\":[{\"id\":\"S123\",\"handle\":\"oncall-team\"},{\"id\":\"S999\",\"handle\":\"other\"}]}"));
|
||||
SlackReadiness.workspace(workspace);
|
||||
assertThat(env.row(SlackTables.USER_GROUP, group).getValueString("handle")).isEqualTo("oncall-team");
|
||||
assertThat(sentText(env)).startsWith("<!subteam^S123|@oncall-team> ");
|
||||
|
||||
env.set(SlackTables.USER_GROUP, group, "handle", "bad|handle>");
|
||||
assertThat(sentText(env)).as("an unsafe handle is ignored").startsWith("<!subteam^S123> ");
|
||||
env.set(SlackTables.USER_GROUP, group, "handle", null);
|
||||
assertThat(sentText(env)).startsWith("<!subteam^S123> ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A rate limit, outage or invalid auth is not an answer: the rows keep what
|
||||
** they had, the loop stops, and the check is a warning, not a failure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void inconclusiveAnswersLeaveTheRowsAlone() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int first = env.channel(workspace, "a", "C1", "a");
|
||||
int second = env.channel(workspace, "b", "C2", "b");
|
||||
env.set(SlackTables.CHANNEL, first, "botIsMember", true);
|
||||
env.set(SlackTables.CHANNEL, second, "botIsMember", true);
|
||||
List<FakeSlackServer.Reply> replies = List.of(new FakeSlackServer.Reply(429, "{\"ok\":false,\"error\":\"ratelimited\"}", Map.of("Retry-After", "30")),
|
||||
new FakeSlackServer.Reply(500, "oops", Map.of()), FakeSlackServer.Reply.error("invalid_auth"));
|
||||
for(FakeSlackServer.Reply reply : replies)
|
||||
{
|
||||
env.slack().clearRequests();
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES)));
|
||||
env.slack().script("conversations.info", reply);
|
||||
var result = SlackReadiness.workspace(workspace);
|
||||
assertThat(result.checks()).anyMatch(c -> c.name().equals("registeredChannels") && c.status().equals("WARN"));
|
||||
assertThat(result.ready()).isTrue();
|
||||
assertThat(env.slack().requests("conversations.info")).as("stops after the first inconclusive answer").hasSize(1);
|
||||
for(int id : List.of(first, second))
|
||||
{
|
||||
QRecord row = env.row(SlackTables.CHANNEL, id);
|
||||
assertThat(row.getValueBoolean("botIsMember")).isTrue();
|
||||
assertThat(row.getValueInstant("checkedAt")).isNull();
|
||||
}
|
||||
}
|
||||
env.slack().script("conversations.info", FakeSlackServer.Reply.of(429, "{\"ok\":false}"));
|
||||
assertThat(SlackReadiness.channel(first)).isEqualTo(SlackReadiness.ChannelCheck.UNKNOWN);
|
||||
assertThat(env.row(SlackTables.CHANNEL, first).getValueBoolean("botIsMember")).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The handle needs only usergroups:read, not chat:write.customize.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void handleSyncNeedsOnlyUsergroupsRead() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
int workspace = env.workspace("T1", null, "xoxb-test", "UBOT");
|
||||
int group = env.userGroup(workspace, "oncall", "S123", "C1");
|
||||
env.slack().script("auth.test", new FakeSlackServer.Reply(200, OK_AUTH, Map.of("x-oauth-scopes", SlackConnect.BOT_SCOPES + ",usergroups:read")));
|
||||
env.slack().script("usergroups.list", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"usergroups\":[{\"id\":\"S123\",\"handle\":\"oncall-team\"}]}"));
|
||||
SlackReadiness.workspace(workspace);
|
||||
assertThat(env.row(SlackTables.USER_GROUP, group).getValueString("handle")).isEqualTo("oncall-team");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored text of a message to the user group, queued as its owner.
|
||||
*******************************************************************************/
|
||||
private String sentText(SlackTestEnv env) throws Exception
|
||||
{
|
||||
env.asOwner("owner");
|
||||
var result = SlackService.enqueue(new SlackSendRequest().withTo("group:oncall").withText("page"));
|
||||
assertThat(result.messageId()).isNotNull();
|
||||
return (env.row(SlackTables.MESSAGE, result.messageId()).getValueString("text"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
import java.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,16 +25,18 @@ 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;
|
||||
@@ -126,6 +128,25 @@ 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()).endsWith("&error=sign_in_required");
|
||||
assertThat(unsigned.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/app/preferences?section=slack&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/preferences?section=slack&result=connected");
|
||||
assertThat(response.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/app/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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
|
||||
import com.kingsrook.qbits.slack.processes.SlackProcesses;
|
||||
import com.kingsrook.qbits.slack.security.SlackSecurity;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the released SPI through the same core action used by the UI API.
|
||||
*******************************************************************************/
|
||||
class SlackPreferencesTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Even administrators see only their own connections; no tokens are exposed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void personalReadOnlyConnections() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, config -> {}))
|
||||
{
|
||||
int workspace = env.workspace("T", null, "xoxb-test", "BOT");
|
||||
int alice = env.connection("alice", workspace, "UA", "xoxp-test");
|
||||
int bob = env.connection("bob", workspace, "UB", null);
|
||||
var connectedAt = java.time.Instant.parse("2026-10-14T12:00:00Z");
|
||||
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", alice).withValue("connectedAt", connectedAt), null);
|
||||
var session = env.asOwner("alice").withPermissions(SlackPermissions.CONNECT, SlackPermissions.ADMIN);
|
||||
SlackSecurity.applyToSession(session, "alice", true);
|
||||
var action = new UserPreferencesAction();
|
||||
var result = action.getSection("slack");
|
||||
var rows = result.getValues().getLists().get("connections");
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getKey()).isEqualTo(String.valueOf(alice));
|
||||
assertThat(rows.get(0).getValues()).containsOnlyKeys("workspace", "status", "connectedAt")
|
||||
.containsEntry("workspace", "Team T").containsEntry("status", "ACTIVE").containsEntry("connectedAt", connectedAt);
|
||||
assertThat(result.getSection().getLists().get(0).getFields()).allSatisfy(field -> assertThat(field.getIsEditable()).isFalse());
|
||||
assertThatThrownBy(() -> action.patchSection("slack", Map.of("lists", Map.of("connections",
|
||||
java.util.List.of(Map.of("key", String.valueOf(alice), "values", Map.of("status", "REVOKED")))))))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed");
|
||||
assertThatThrownBy(() -> action.patchSection("slack", Map.of())).isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed");
|
||||
for(String name : Set.of(SlackProcesses.DISCONNECT, SlackProcesses.TEST_MESSAGE))
|
||||
{
|
||||
assertThatThrownBy(() -> action.runAction("slack", name, Map.of("rowKey", String.valueOf(bob))))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("isn't available for this row");
|
||||
}
|
||||
env.asOwner("bob").withPermissions(SlackPermissions.CONNECT);
|
||||
assertThat(action.getSection("slack").getValues().getLists().get("connections"))
|
||||
.extracting(row -> row.getKey()).containsExactly(String.valueOf(bob));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Action visibility is session-specific and forbidden actions cannot be invoked.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissionFilteredActionsAndAuthorizeUrls() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, config -> config.withPublicBaseUrl("http://localhost:8099")
|
||||
.withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "demo-secret")))
|
||||
{
|
||||
int workspace = env.workspace("T", null, "xoxb-test", "BOT");
|
||||
int own = env.connection("alice", workspace, "UA", null);
|
||||
var session = env.asOwner("alice");
|
||||
session.setPermissions(Set.of(SlackPermissions.CONNECT));
|
||||
var action = new UserPreferencesAction();
|
||||
assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName)
|
||||
.containsExactly(SlackProcesses.CONNECT, SlackProcesses.DISCONNECT);
|
||||
assertThat(action.getSection("slack").getValues().getLists().get("connections").get(0).getActionNames())
|
||||
.containsExactly(SlackProcesses.DISCONNECT);
|
||||
assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.INSTALL, Map.of())).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.TEST_MESSAGE, Map.of("rowKey", String.valueOf(own))))
|
||||
.isInstanceOf(QNotFoundException.class);
|
||||
assertThat(action.runAction("slack", SlackProcesses.CONNECT, Map.of()).getMessage()).startsWith("https://slack.com/oauth/v2/authorize?").contains("state=");
|
||||
session.setPermissions(Set.of(SlackPermissions.CONNECT, SlackPermissions.INSTALL, SlackPermissions.SEND));
|
||||
assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName)
|
||||
.contains(SlackProcesses.INSTALL, SlackProcesses.TEST_MESSAGE);
|
||||
assertThat(action.runAction("slack", SlackProcesses.INSTALL, Map.of()).getMessage()).contains("slack.com/oauth/v2/authorize", "scope=chat");
|
||||
session.setPermissions(Set.of(SlackPermissions.CONNECT));
|
||||
assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName)
|
||||
.doesNotContain(SlackProcesses.INSTALL, SlackProcesses.TEST_MESSAGE);
|
||||
session.setPermissions(Set.of(SlackPermissions.VIEW));
|
||||
assertThat(action.listSections()).isEmpty();
|
||||
assertThatThrownBy(() -> action.getSection("slack")).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test sends enqueue only; disconnect removes the owner's access and actions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rowActions() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, config -> {}))
|
||||
{
|
||||
int workspace = env.workspace("T", null, "xoxb-test", "BOT");
|
||||
int own = env.connection("alice", workspace, "UA", null);
|
||||
env.asOwner("alice").withPermissions(SlackPermissions.CONNECT);
|
||||
var action = new UserPreferencesAction();
|
||||
var body = Map.of("rowKey", String.valueOf(own));
|
||||
assertThat(action.runAction("slack", SlackProcesses.TEST_MESSAGE, body).getMessage()).contains("queued");
|
||||
assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(1);
|
||||
assertThat(env.slack().requests("chat.postMessage")).isEmpty();
|
||||
var result = action.runAction("slack", SlackProcesses.DISCONNECT, body);
|
||||
assertThat(result.getSection().getValues().getLists().get("connections").get(0).getValues()).containsEntry("status", "REVOKED");
|
||||
assertThat(result.getSection().getValues().getLists().get("connections").get(0).getActionNames()).isEmpty();
|
||||
assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.TEST_MESSAGE, body)).isInstanceOf(QBadRequestException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,33 +22,36 @@ package com.kingsrook.qbits.slack;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.slack.api.SlackApi;
|
||||
import com.kingsrook.qbits.slack.api.SlackApiTemplate;
|
||||
import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProvider;
|
||||
import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.processes.SlackProcesses;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Attachment;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The core messaging provider: core SendMessageAction with the provider named
|
||||
** "slack" queues rows for the current user, and its refusals reach the caller
|
||||
** as user-facing exceptions. The scheduled dispatch process delivers them.
|
||||
** as structured outcomes. The scheduled dispatch process delivers them.
|
||||
*******************************************************************************/
|
||||
class SlackProviderTest
|
||||
{
|
||||
@@ -105,7 +108,8 @@ class SlackProviderTest
|
||||
{
|
||||
env.asOwner("userA").withPermissions("slack.message.body");
|
||||
SendMessageOutput output = new SendMessageAction().execute(input(new Party().withAddress("me")));
|
||||
assertThat(output).isNotNull();
|
||||
assertThat(output.getStatus()).isEqualTo(SendMessageStatus.QUEUED);
|
||||
assertThat(output.getMessageId()).isNotNull();
|
||||
|
||||
List<QRecord> rows = SlackH2Support.query(SlackTables.MESSAGE);
|
||||
assertThat(rows).hasSize(1);
|
||||
@@ -136,33 +140,91 @@ class SlackProviderTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A refusal is a user-facing exception with the reason: an unconnected user,
|
||||
** a channel the user isn't in, a missing recipient and an attachment.
|
||||
** Recipients that can be queued still are.
|
||||
** Refusals use RC.7 status and reasons; partially accepted recipients retain IDs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRefusalsAreUserFacingExceptions() throws Exception
|
||||
void testRefusalsAreStructured() throws Exception
|
||||
{
|
||||
env.asOwner("userC");
|
||||
assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me"))))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessageContaining("Connect Slack");
|
||||
|
||||
assertRefused(input(new Party().withAddress("me")), "Connect Slack");
|
||||
env.asOwner("userA");
|
||||
env.slack().members("C2", "UOTHER");
|
||||
assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("channel:" + workspaceId + "/C2"))))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessageContaining("aren't a member");
|
||||
assertThatThrownBy(() -> new SendMessageAction().execute(input(null)))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessageContaining("recipient");
|
||||
assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me")).withAttachment(new Attachment())))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessageContaining("attachments");
|
||||
|
||||
assertThatThrownBy(() -> new SendMessageAction().execute(input(new MultiParty().withParty(new Party().withAddress("me")).withParty(new Party().withAddress("garbage")))))
|
||||
.isInstanceOf(QUserFacingException.class).hasMessageContaining("isn't recognized");
|
||||
assertRefused(input(new Party().withAddress("channel:" + workspaceId + "/C2")), "aren't a member");
|
||||
assertRefused(input(null), "recipient");
|
||||
assertRefused(input(new Party().withAddress("me")).withAttachment(new Attachment()), "attachments");
|
||||
SendMessageOutput partial = new SendMessageAction().execute(input(new MultiParty().withParty(new Party().withAddress("me"))
|
||||
.withParty(new Party().withAddress("garbage"))));
|
||||
assertThat(partial.getStatus()).isEqualTo(SendMessageStatus.REFUSED);
|
||||
assertThat(partial.getReasons().toString()).contains("isn't recognized");
|
||||
assertThat(partial.getMessageId()).isNotNull();
|
||||
assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert both the status and useful reason through core's real action.
|
||||
*******************************************************************************/
|
||||
private void assertRefused(SendMessageInput input, String reason) throws Exception
|
||||
{
|
||||
SendMessageOutput result = new SendMessageAction().execute(input);
|
||||
assertThat(result.getStatus()).isEqualTo(SendMessageStatus.REFUSED);
|
||||
assertThat(result.getReasons().toString()).contains(reason);
|
||||
assertThat(result.getMessageId()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rich fields reach the outbox and repeat sends report the original row ID.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void richInputAndDeduplication() throws Exception
|
||||
{
|
||||
env.asSystem();
|
||||
SlackApi.ensureTemplate(new SlackApiTemplate("hello", "Hello", "Hello {{name}}", null,
|
||||
"[{\"name\":\"name\",\"type\":\"STRING\"}]", "TRANSACTIONAL"));
|
||||
env.asOwner("userA").withPermissions("slack.message.body");
|
||||
assertThat(SendMessageAction.capabilities("slack")).containsExactlyInAnyOrder(MessagingProviderCapability.TEMPLATES,
|
||||
MessagingProviderCapability.IDEMPOTENCY, MessagingProviderCapability.CATEGORIES,
|
||||
MessagingProviderCapability.LINKED_RECORDS, MessagingProviderCapability.ASYNC_DELIVERY);
|
||||
SendMessageInput input = input(new Party().withAddress("me")).withTemplateName("hello").withVariables(Map.of("name", "Alice"))
|
||||
.withIdempotencyKey("rich-input").withCategory("TRANSACTIONAL").withSenderName("Reports")
|
||||
.withLinkedRecord(SlackTables.WORKSPACE, workspaceId).withLocale("en-US");
|
||||
SendMessageOutput first = new SendMessageAction().execute(input);
|
||||
assertThat(first.getStatus()).isEqualTo(SendMessageStatus.QUEUED);
|
||||
SendMessageOutput second = new SendMessageAction().execute(input);
|
||||
assertThat(second.getStatus()).isEqualTo(SendMessageStatus.DUPLICATE);
|
||||
assertThat(second.getMessageId()).isEqualTo(first.getMessageId());
|
||||
var rows = SlackH2Support.query(SlackTables.MESSAGE);
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).getValues()).containsEntry("templateName", "hello").containsEntry("category", "TRANSACTIONAL")
|
||||
.containsEntry("senderName", "Reports").containsEntry("linkedTableName", SlackTables.WORKSPACE)
|
||||
.containsEntry("linkedRecordId", String.valueOf(workspaceId)).containsEntry("text", "Hello Alice");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One key must not collapse multiple recipients onto the first recipient's row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void multiPartyIdempotency() throws Exception
|
||||
{
|
||||
env.asOwner("userA");
|
||||
SendMessageInput input = input(new MultiParty().withParty(new Party().withAddress("me"))
|
||||
.withParty(new Party().withAddress("channel:" + workspaceId + "/C1"))).withIdempotencyKey("multi-input");
|
||||
SendMessageOutput first = new SendMessageAction().execute(input);
|
||||
assertThat(first.getStatus()).isEqualTo(SendMessageStatus.QUEUED);
|
||||
assertThat((List<?>) first.getMessageId()).hasSize(2);
|
||||
SendMessageOutput second = new SendMessageAction().execute(input);
|
||||
assertThat(second.getStatus()).isEqualTo(SendMessageStatus.DUPLICATE);
|
||||
assertThat(second.getMessageId()).isEqualTo(first.getMessageId());
|
||||
assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider type is registered with core's dispatcher, the producer
|
||||
** registers the provider under the configured name, and the dispatch process
|
||||
|
||||
@@ -45,6 +45,8 @@ class SlackQBitProducerTest
|
||||
SlackQBitProducer producer = new SlackQBitProducer().withQBitConfig(config).withNamespace("ns");
|
||||
|
||||
QBitMetaData metaData = producer.getQBitMetaData();
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
|
||||
assertThat(metaData.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(SlackQBitProducer.VERSION);
|
||||
assertThat(metaData.getArtifactId()).isEqualTo("qbit-slack");
|
||||
assertThat(metaData.getGroupId()).isEqualTo("com.kingsrook.qbits");
|
||||
assertThat(producer.getNamespace()).isEqualTo("ns");
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -62,6 +63,58 @@ class SlackReadinessTest
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No Slack secrets at all is "not configured": UNKNOWN, never DOWN, so an
|
||||
** optional, unset Slack integration can't fail a readiness probe.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notConfiguredIsUnknownNotDown() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
env.config().withSecretResolver(n -> null);
|
||||
assertThat(SlackReadiness.deployment(env.instance()).checks())
|
||||
.anyMatch(c -> c.name().equals("app") && c.status().equals(SlackReadiness.NOT_CONFIGURED));
|
||||
var result = new SlackHealth().check(env.instance());
|
||||
assertThat(result.getStatus().name()).isEqualTo("UNKNOWN");
|
||||
assertThat(result.getDetails()).containsEntry("configured", false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Before readiness has run, health is UNKNOWN rather than degraded or down.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notYetCheckedIsUnknown() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
QInstance fresh = new QInstance();
|
||||
assertThat(new SlackHealth().check(fresh).getStatus().name()).isEqualTo("UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Partially configured (some secrets set, others missing) is a real
|
||||
** misconfiguration and stays DOWN.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void partiallyConfiguredIsDown() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
env.config().withSecretResolver(n -> "SLACK_CLIENT_ID".equals(n) ? "123.456" : null);
|
||||
SlackReadiness.deployment(env.instance());
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DOWN");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A valid Base64 string still needs exactly 32 decoded bytes.
|
||||
*******************************************************************************/
|
||||
@@ -166,7 +219,7 @@ class SlackReadinessTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Uninitialized readiness maps to degraded health without network access.
|
||||
** Uninitialized readiness maps to UNKNOWN health without network access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notReadyHealth() throws Exception
|
||||
@@ -174,7 +227,7 @@ class SlackReadinessTest
|
||||
try(SlackTestEnv env = environment())
|
||||
{
|
||||
assertThat(SlackReadiness.stored(env.instance()).ready()).isFalse();
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("DEGRADED");
|
||||
assertThat(new SlackHealth().check(env.instance()).getStatus().name()).isEqualTo("UNKNOWN");
|
||||
assertThat(env.slack().requests()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Startup must use the released core preferences SPI.
|
||||
*******************************************************************************/
|
||||
class SlackStartupTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Produce into a host instance and validate the registered section.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registersPreferencesOnRc7() throws Exception
|
||||
{
|
||||
var instance = SlackH2Support.instance(SlackH2Support.backend(), config -> {});
|
||||
new QInstanceValidator().validate(instance);
|
||||
assertThat(UserPreferencesMetaData.of(instance).getSections())
|
||||
.anySatisfy(section ->
|
||||
{
|
||||
assertThat(section.getName()).isEqualTo("slack");
|
||||
assertThat(section.getPermissionName()).isEqualTo("slack.connect");
|
||||
assertThat(section.getShowInUserMenu()).isTrue();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.slack;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.slack.metadata.SlackTables;
|
||||
import com.kingsrook.qbits.slack.support.SlackStore;
|
||||
import com.kingsrook.qbits.slack.template.SlackTemplates;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every slack_* table:
|
||||
** - runtime code (not just its metadata or home screen) reads or writes it;
|
||||
** - it is either seeded by the qbit at startup, or listed here with why it
|
||||
** starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get created
|
||||
** (shown on the table screen and when it is empty).
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class SlackTablesGuardrailTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "slack");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables the qbit seeds itself (SlackTemplates.seed: idempotent, by name).
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> SEEDED = Map.of("slack_template", "SlackTemplates.seed(): notification, digest and delivery");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("slack_workspace", "OAuth install: an administrator runs 'Install for my organization'"),
|
||||
Map.entry("slack_connection", "OAuth connect: each user runs 'Connect Slack'"),
|
||||
Map.entry("slack_channel", "host data: the channels a host posts to, with Slack IDs; 'Check channels' fills their status"),
|
||||
Map.entry("slack_user_group", "host data: the user groups a host mentions, with Slack IDs"),
|
||||
Map.entry("slack_message", "runtime log: every queued send"),
|
||||
Map.entry("slack_message_attempt", "runtime log: every delivery attempt"),
|
||||
Map.entry("slack_interaction", "runtime log: every button click or Slack event"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> constantByTable = constantByTable();
|
||||
assertThat(constantByTable.keySet()).containsAll(SlackTables.ALL);
|
||||
List<String> runtimeSource = new ArrayList<>();
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
files.filter(p -> p.toString().endsWith(".java") && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")))
|
||||
.forEach(p -> runtimeSource.add(read(p)));
|
||||
}
|
||||
for(String table : SlackTables.ALL)
|
||||
{
|
||||
String constant = constantByTable.get(table);
|
||||
Pattern use = Pattern.compile("SlackTables\\." + constant + "\\b");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (SlackTables." + constant + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained()
|
||||
{
|
||||
for(String table : SlackTables.ALL)
|
||||
{
|
||||
assertThat(SEEDED.containsKey(table) || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(SEEDED.containsKey(table) && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both").isFalse();
|
||||
}
|
||||
assertThat(SlackTables.ALL).containsAll(STARTS_EMPTY.keySet()).containsAll(SEEDED.keySet());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The seed fills the template table, running it again adds nothing, and an
|
||||
** administrator's edit survives.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedIsInsertOnly() throws Exception
|
||||
{
|
||||
try(SlackTestEnv env = SlackTestEnv.create(false, c ->
|
||||
{
|
||||
}))
|
||||
{
|
||||
env.asSystem();
|
||||
SlackTemplates.seed();
|
||||
List<QRecord> first = SlackStore.queryAsCurrent(SlackTables.TEMPLATE, new QQueryFilter());
|
||||
assertThat(first).extracting(r -> r.getValueString("name")).containsExactlyInAnyOrder("notification", "digest", "delivery");
|
||||
assertThat(first).allSatisfy(r -> assertThat(r.getValueBoolean("enabled")).isTrue());
|
||||
|
||||
int id = first.stream().filter(r -> "digest".equals(r.getValueString("name"))).findFirst().orElseThrow().getValueInteger("id");
|
||||
SlackStore.update(SlackTables.TEMPLATE, new QRecord().withValue("id", id).withValue("label", "Edited by an admin"), null);
|
||||
SlackTemplates.seed();
|
||||
assertThat(SlackStore.queryAsCurrent(SlackTables.TEMPLATE, new QQueryFilter())).hasSize(3);
|
||||
assertThat(SlackStore.get(SlackTables.TEMPLATE, id).getValueString("label")).isEqualTo("Edited by an admin");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
QInstance instance = SlackH2Support.setup(c ->
|
||||
{
|
||||
});
|
||||
HelpBundleAssertions.forInstance(instance)
|
||||
.bundles("help/qbit-slack.yaml")
|
||||
.owning(target -> target.name().startsWith("slack"))
|
||||
.assertComplete();
|
||||
var bundles = HelpBundles.of(instance).getBundles();
|
||||
assertThat(bundles).hasSize(1);
|
||||
assertThat(bundles.get(0).getId()).isEqualTo("qbit-slack");
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
|
||||
assertThat(bundles.get(0).getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(SlackQBitProducer.VERSION);
|
||||
for(String table : SlackTables.ALL)
|
||||
{
|
||||
assertThat(bundles.get(0).getEntries()).filteredOn(entry -> entry.getKey().equals("table:" + table + ";slot:help"))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.getContent()).isNotBlank());
|
||||
}
|
||||
// Hosts without the help table still register metadata; core safely skips sync.
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SlackTables declares CONSTANT = "slack_x"; table name to constant name.
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> constantByTable()
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Matcher m = Pattern.compile("public static final String ([A-Z_]+)\\s*=\\s*\"(slack_[a-z_]+)\";").matcher(read(MAIN.resolve("metadata").resolve("SlackTables.java")));
|
||||
while(m.find())
|
||||
{
|
||||
rs.put(m.group(2), m.group(1));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user