Compare commits

...
Author SHA1 Message Date
dependabot[bot] 53fd036f58 build(deps): Bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml](https://github.com/qrun-io/qqq-orb) and [QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml](https://github.com/qrun-io/qqq-orb).


Updates `QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml` from c288a8864fe9054f68410e1d78f5863aea4c4d22 to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/c288a8864fe9054f68410e1d78f5863aea4c4d22...1c389210e6dfda1630d053c07ae4dae523dd5576)

Updates `QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml` from cc30f599e63c8e41629c99d23395bac1adb8bc44 to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/cc30f599e63c8e41629c99d23395bac1adb8bc44...1c389210e6dfda1630d053c07ae4dae523dd5576)

---
updated-dependencies:
- dependency-name: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-28 08:25:02 +00:00
james.maesandCodex 33ba265215 ci: adopt managed orb 0.8 patch track (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:28:28 -05:00
james.maesandCodex 7a0fd3762a ci: adopt shared orb 0.7.18 (#25)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:34 -05:00
james.maesandCodex 2682c2fa94 ci: adopt shared orb 0.7.17 (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:16:09 -05:00
james.maesandCodex 42aaf3c9c8 ci: use the shared branch-aware CodeQL worker (#22)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:43:15 -05:00
james.maesandCodex a40eade67b ci: adopt central orb 0.7.12 and stable gates (#20)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:50:01 -05:00
james.maesandCodex f0b70d7da7 ci: centralize rc release completion in qqq orb (#18)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:23:37 -05:00
james.maesandCodex 4dcabdffed ci: adopt shared orb 0.7.11 (#17)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:57:21 -05:00
james.maes de09fc27e9 Merge pull request #16 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb 0.7.4
2026-10-20 19:33:40 -05:00
james.maesandCodex fb4ae6db07 ci: adopt central QQQ orb 0.7.4
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:26:57 -05:00
james.maes 2f7025ef28 Merge pull request #15 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:45 -05:00
james.maesandClaude Opus 5.5 33d993f414 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:23 -05:00
james.maes 87a2218ef6 Merge pull request #14 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 56b6f48a7b fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-slack.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:33:09 -05:00
james.maes 80083044a8 Merge pull request #13 from QRun-IO/fix/health-not-configured
fix(health): unconfigured Slack is UNKNOWN, never DOWN
2026-10-14 23:57:11 -05:00
james.maesandClaude Opus 5.5 1811bbf211 fix(health): an unconfigured Slack app is UNKNOWN, never DOWN
With no SLACK_* secrets at all the app readiness check is NOT_CONFIGURED and
the health indicator reports UNKNOWN (HTTP 200) with configured=false, so an
optional, unset Slack integration can't fail a readiness probe (it took kof22's
probe down). Not-yet-checked is UNKNOWN too. Partial or invalid configuration
stays DOWN. Tests for all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:54:33 -05:00
james.maes 070e2c95d9 Merge pull request #12 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 7f71fc0566 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:10:36 -05:00
james.maes 91b24cb986 Merge pull request #11 from QRun-IO/feature/help-bundle
feat(help): qbit-slack help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
james.maesandClaude Opus 5.5 36def54b8b feat(help): ship the qbit-slack help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:37:13 -05:00
james.maes c28210353e Merge pull request #10 from QRun-IO/fix/rc7-preferences
fix: start on QQQ 4.1.0-RC.7 (real user-preferences SPI; #1194 fields)
2026-10-14 19:51:17 -05:00
james.maesandClaude Opus 5.5 37524048ba refactor(bridge): core #1194 rich messaging fields on RC.7, drop the stand-in
The QBIT_SLACK provider declares its capabilities and returns status, message
id and reasons through core SendMessageOutput; idempotency keys and templates
come from SendMessageInput.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:48:26 -05:00
james.maesandClaude Opus 5.5 1ddbf470de fix(preferences): real core user-preferences SPI on QQQ 4.1.0-RC.7
RC.1-RC.3 failed to start on RC.7: the reflection adapter (written against the
#1162 stand-in) called a withSection signature RC.7 doesn't have. Replace it with
SlackPreferencesProvider on the real SPI (section, connections list, Connect,
Install, test message and Disconnect actions, read-only store), registered only
when the core classes exist. Startup test validates the instance on RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:48:26 -05:00
james.maes c1064601da Merge pull request #9 from QRun-IO/feature/tables-populated-defaults
feat: every slack table explains itself; channel status and group handle wired
2026-10-14 17:42:44 -05:00
james.maesandClaude Opus 5.5 d8063498a2 fix: only persist definitive channel answers; Check channels reporting; hostHelpIsKept
- 429, 5xx, timeouts and invalid_auth leave channel rows alone, stop the
  workspace loop and make the channels check a warning.
- Handle sync needs only usergroups:read.
- Check channels reports warnings and per-row errors; status fields are
  read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:26:20 -05:00
james.maesandClaude Opus 5.5 4bc330d96b feat: table help, channel status write-back, user group handle, tables guardrail
Every slack_* table and the app get help bundle text saying how rows get
created. Readiness and a new Check channels action persist the channel's
Slack name, privacy, bot membership, archived, purpose and checked time.
User group handle is resolved from usergroups.list and used in mentions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:04:29 -05:00
james.maes c5691102a2 Merge pull request #8 from QRun-IO/feature/interactivity
feat: phase 2 - interactive buttons as the clicking user, Events API revocations
2026-10-14 14:56:06 -05:00
james.maesandClaude Opus 5.5 0c298f893e feat(interactivity): button actions run as the clicking user; Events API revocations
POST /qqq/hooks/slack/interactions: verified, acknowledged, token checked
against the stored message, Slack user mapped to a connection in the same
workspace row, process run in that user's session with permission, tenant and
record locks; idempotent message update; ephemeral replies only to
hooks.slack.com. POST /qqq/hooks/slack/events: url_verification,
tokens_revoked and app_uninstalled across every tenant row of the team.
Built-in acknowledge_alert / resolve_alert when those processes exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:49:23 -05:00
james.maesandClaude Opus 5.5 fb93a01a41 feat(metadata): slack_interaction audit table (child of messages)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:49:22 -05:00
james.maesandClaude Opus 5.5 d486153ca8 feat(security): Slack request signature verifier and signed button tokens
v0 HMAC-SHA256 over timestamp and raw body, 300 s window, constant-time
compare; button values are server-signed (message, action, record, expiry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:49:21 -05:00
james.maes 1d21f32835 Merge pull request #7 from QRun-IO/feature/delivery-destination
feat: phase 1e - Slack delivery destination, file uploads, SlackApi.applySessionKeys
2026-10-14 13:33:24 -05:00
james.maes f204e68331 Merge remote-tracking branch 'origin/develop' into feature/delivery-destination 2026-10-14 13:30:40 -05:00
james.maesandClaude Opus 5.5 8d5fa6d462 feat(delivery): Slack delivery destination and SlackApi.applySessionKeys
SlackDeliveryDestination: contingent pickers (workspace, then DM to me or a
channel the user is a member of), owner re-authorization, delivery template,
attachments as FILE messages, lookup mapping; built against
STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8) copies of the core
delivery types. SlackApi.applySessionKeys so hosts stop using internal
SlackSecurity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:30:38 -05:00
james.maesandClaude Opus 5.5 c0eab35fde feat(outbox): FILE messages via Slack's external upload flow
files.getUploadURLExternal, a streamed upload from core storage (no temp
files), files.completeUploadExternal; expired-lease reconcile by title marker
through files.list; files:read scope added to the manifest and design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:30:37 -05:00
james.maes 3bbd282016 Merge pull request #6 from QRun-IO/feature/app-home
feat: phase 1f - Slack app, home dashboard, permissions, integrations, preferences
2026-10-14 13:13:57 -05:00
james.maesandClaude Opus 5.5 cbac87b0d8 fix(home): typed tables and statistic tiles instead of text blocks
Connections, recent deliveries, readiness and failures as linked tables;
non-zero status tiles linking to filtered Messages; local dates and readable
status labels; Fix hints only when not ready; one title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:10:37 -05:00
james.maes 21553d67c1 Merge remote-tracking branch 'origin/develop' into feature/app-home 2026-10-14 12:49:18 -05:00
james.maesandClaude Opus 5.5 0bb2b463f0 feat(app): Slack app, viewer-scoped home dashboard and demo server
Nav lists only the six main tables; the home shows the user's connections and
deliveries and, for slack.admin, workspace readiness, outbox counts and recent
failures, all through owner and tenant locks. Test-scope demo server with the
fake Slack server and seeded data for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:47:44 -05:00
james.maesandClaude Opus 5.5 6f81857cbe feat(preferences): Slack preferences section adapter and Connections record actions
Registers only when core user preferences exist:
STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.8). Connect, Disconnect,
Send me a test message and Install for my organization also work today as
record actions on Connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:47:43 -05:00
james.maesandClaude Opus 5.5 3f43130f14 feat(permissions): slack.* permissions, body masking, token-free API/ESB/Quick Search lists
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:47:43 -05:00
james.maes 2dab9e516c Merge pull request #5 from QRun-IO/feature/api-connect
feat: phase 1c/1d - Connect Slack (OAuth), SlackApi, readiness and health, templates, PHI guard
2026-10-14 12:37:42 -05:00
james.maes ce903dc5c1 Merge remote-tracking branch 'origin/develop' into feature/api-connect 2026-10-14 12:28:55 -05:00
james.maesandClaude Opus 5.5 f58c2318a3 feat(api): SlackApi public package, readiness and optional health check
isInstalled/send/sendOnce/reply/update/lookups/readiness/connectionsFor/
listTargets/ensureTemplate with token-free projections; readiness checks for
app, workspace, connection, registered channels and outbox, Check readiness
action, scheduled refresh; health registered only when qqq-middleware-health is
present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:28:25 -05:00
james.maesandClaude Opus 5.5 c795e9fa01 feat(oauth): Connect Slack per user and Install per tenant
Signed, expiring OAuth state bound to user, tenant and session (stateless across
pods; per-pod replay set), callback route via the javalin extension (provided),
row-bound encrypted tokens, Disconnect (auth.revoke) and Uninstall actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:28:24 -05:00
james.maesandClaude Opus 5.5 34035be6b2 feat(templates): Slack template engine and PHI payload guard
Email-compatible variable language with mrkdwn escaping, Block Kit variables in
string positions only, size and block limits, built-in notification, digest and
delivery templates; PHI/PII variables tokenized or refused, SSN/MRN patterns
refused, SECURITY sends title and link only, canary test.
STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.8) for classified value tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:28:22 -05:00
james.maes 1e40970459 Merge pull request #2 from QRun-IO/feature/outbox-core
feat: phase 1b - tables, token cipher, owner/tenant authorization, outbox, dispatcher, core provider
2026-10-14 11:54:05 -05:00
james.maes 06eba71448 Merge remote-tracking branch 'origin/develop' into feature/outbox-core 2026-10-14 11:51:22 -05:00
james.maesandClaude Opus 5.5 2bb5fa7bc2 feat(bridge): QBIT_SLACK core messaging provider
Registered under bridgeProviderName (default slack) so callers use core
SendMessageAction only. STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8)
for output status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:51:03 -05:00
james.maesandClaude Opus 5.5 8d966e9bbe feat(outbox): durable outbox and dispatcher with exactly-once posting
Claim and lease with attempt rows; re-authorization as the owner at send time;
message-metadata reconcile of expired leases; 429 Retry-After without consuming
attempts; retries, dead letters, FAILED_AUTH with owner notification and alert;
threads, updates, rate limiter, workspace health. Two-tenant and token-leak tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:51:03 -05:00
james.maesandClaude Opus 5.5 efc7a7c82b feat(security): owner-session authorizer with membership check
Workspace and connection lookups run as the owner (createAutomatedSessionForUser)
so tenant and owner locks apply; conversations.members must include the owner;
bounded membership cache; only the chosen row's token is ever decrypted;
ownerless sends limited to registered channels with a matching tenant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:51:02 -05:00
james.maesandClaude Opus 5.5 a45bb8a75e feat(transport): Slack Web API client, error classifier and fake test server
Bearer token per call (never stored), JSON writes, paged reads, Retry-After and
x-oauth-scopes, scrubbed errors, configurable timeouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:51:01 -05:00
james.maesandClaude Opus 5.5 ac2427220e feat(metadata): slack_ tables with owner and tenant locks and hidden tokens
Seven tables (workspace, connection, channel, user group, template, message,
attempt), enums, joins, child lists and Postgres DDL. Owner locks on
connection and message; the host tenant lock on workspace and inherited by
join; token fields PASSWORD, hidden, and DENY-locked for every user session.
Tests: validator, owner isolation, two-tenant isolation, token invisibility.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:18:52 -05:00
james.maesandClaude Opus 5.5 82c13f2ff5 feat(security): AES-GCM token cipher bound to its row, and error scrubbing
Keys from named environment variables (current and previous), generic errors
with no cause, a canary test that a fake token never escapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:18:51 -05:00
james.maesandClaude Opus 5.5 ab38448c51 feat(config): phase 1 settings, secret env-name allow list and validation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:18:50 -05:00
james.maes 317432b1d1 Merge pull request #1 from QRun-IO/feature/spec-and-scaffold
Design and scaffold: qbit-slack (per-user, multi-tenant Slack, delivery destinations)
2026-10-14 11:02:21 -05:00
james.maesandClaude Opus 5.5 cae5b88fa1 docs: design for qbit-slack (per-user, multi-tenant, delivery destinations)
Owner decisions (2026-10-14): all recommendations accepted (standing approval).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:59:39 -05:00
james.maesandClaude Opus 5.5 24be00bafc build: scaffold per the QRun qbit repository standard
Coordinates com.kingsrook.qbits:qbit-slack, qbit-build-parent 2.1.1 with
guardrails enforced, qqq-orb 0.6.9, QQQ 4.1.0-RC.6, Checkstyle and the
JaCoCo gate. Minimal SlackQBitProducer and SlackQBitConfig with a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:59:38 -05:00
138 changed files with 25376 additions and 304 deletions

No files matched your search

+11 -18
View File
@@ -1,21 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.5.0
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:
build:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+20
View File
@@ -0,0 +1,20 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
+25
View File
@@ -0,0 +1,25 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
+31
View File
@@ -0,0 +1,31 @@
# GitHub Release Configuration
# Auto-generates release notes from PR labels
changelog:
exclude:
labels:
- ignore-for-release
- duplicate
- invalid
authors:
- dependabot
- dependabot[bot]
categories:
- title: Breaking Changes
labels:
- breaking-change
- breaking
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Dependencies
labels:
- dependencies
- title: Other Changes
labels:
- "*"
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
+74
View File
@@ -0,0 +1,74 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
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
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16 -15
View File
@@ -1,20 +1,21 @@
# Maven
target/
.env
.idea
*.iml
*.class
*.log
*.jar
*.war
*.nar
*.ear
# IDE
.idea/
*.iml
.vscode/
.project
.classpath
.settings/
# OS
*.zip
*.tar.gz
*.rar
hs_err_pid*
.DS_Store
Thumbs.db
# Logs
*.log
*.sw*
.flattened-pom.xml
dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+16
View File
@@ -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
+49
View File
@@ -0,0 +1,49 @@
# AGENTS.md
This file provides guidance to coding agents when working with code in this repository.
## Project Overview
**QBit Slack**: Slack for a QQQ system, with per-user and per-tenant connections, a core messaging
provider and delivery destination, a durable outbox, templates, readiness and PHI-safe payloads.
Design: `docs/DESIGN.md`.
## Build Commands
```bash
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test
./mvnw verify # tests + Checkstyle + guardrails + JaCoCo gate (70% instructions, 90% classes)
```
## Architecture
- **Entry point:** `SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>`, package `com.kingsrook.qbits.slack`.
- `api` is the only public package (mirrors qbit-email's `EmailApi`). Everything else is internal.
## Invariants
- Every message has an owner (a QQQ user) and is authorized as that owner when queued and again when sent.
- A message only ever uses its own workspace row's or connection row's token. There is no fallback.
- Tokens are encrypted, write-only and HIDDEN; they never reach a log, an exception, an audit or an API.
- Every send goes through the outbox (`slack_message`); nothing posts inline.
- Classified PHI and PII never enter a Slack payload.
## Naming (owner rule)
- Tables are lowercase snake_case, prefixed `slack_`; the backend name is the table name.
- Permissions use the full qbit name: `slack.view`, `slack.connect`, `slack.send`, `slack.admin`.
- Data lives in standard visible tables. Processes are table or record actions, never in the nav.
- UI goes through the `/qqq/v1` UI API only; Slack callbacks are under `/qqq/hooks/slack/...`.
## Code Style
3-space indent, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters.
## Release
- CircleCI qqq-orb 0.6.9.
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs (automated by `.github/workflows/release-rc.yml`).
- `vX.Y.Z` tags publish releases (GA tags only).
- Never push `hotfix/*` or `release/*` branches by hand; use `feature/` or `fix/`.
+18
View File
@@ -0,0 +1,18 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- QBit metadata now reports the Maven artifact version, including release versions, from a filtered properties resource.
### Added
- Complete host-neutral in-app help bundle with live labels and core `HelpBundleAssertions` coverage; registered through QQQ 4.1.0-RC.7 `HelpBundles`, replacing the temporary loader.
- "Check channels" action on Registered Channels; readiness now writes the channel's Slack name, private, bot is member, archived, purpose and checked time back to the row.
- User group `handle` is resolved from `usergroups.list` during readiness (optional `usergroups:read` scope) and used in the mention (`<!subteam^ID|@handle>`).
- `SlackTablesGuardrailTest`: every table is used at runtime, seeded or listed as starting empty, and has help.
- Design (`docs/DESIGN.md`) and the Slack app manifest (`docs/slack-app-manifest.yaml`).
- Repository scaffold per the QRun qbit repository standard: `SlackQBitProducer` and `SlackQBitConfig`.
+42 -10
View File
@@ -1,17 +1,49 @@
# qbit-template-extension
# CLAUDE.md
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
This file provides guidance to Claude Code when working with code in this repository.
## QQQ 4.0 baseline — 2026-09-24
## Project Overview
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
**QBit Slack**: Slack for a QQQ system, with per-user and per-tenant connections, a core messaging
provider and delivery destination, a durable outbox, templates, readiness and PHI-safe payloads.
Design: `docs/DESIGN.md`.
## Knowledge base
## Build Commands
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
```bash
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
./mvnw test
./mvnw verify # tests + Checkstyle + guardrails + JaCoCo gate (70% instructions, 90% classes)
```
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
## Architecture
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
- **Entry point:** `SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>`, package `com.kingsrook.qbits.slack`.
- `api` is the only public package (mirrors qbit-email's `EmailApi`). Everything else is internal.
## Invariants
- Every message has an owner (a QQQ user) and is authorized as that owner when queued and again when sent.
- A message only ever uses its own workspace row's or connection row's token. There is no fallback.
- Tokens are encrypted, write-only and HIDDEN; they never reach a log, an exception, an audit or an API.
- Every send goes through the outbox (`slack_message`); nothing posts inline.
- Classified PHI and PII never enter a Slack payload.
## Naming (owner rule)
- Tables are lowercase snake_case, prefixed `slack_`; the backend name is the table name.
- Permissions use the full qbit name: `slack.view`, `slack.connect`, `slack.send`, `slack.admin`.
- Data lives in standard visible tables. Processes are table or record actions, never in the nav.
- UI goes through the `/qqq/v1` UI API only; Slack callbacks are under `/qqq/hooks/slack/...`.
## Code Style
3-space indent, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters.
## Release
- CircleCI qqq-orb 0.6.9.
- `develop` publishes SNAPSHOTs.
- `release/*` publishes RCs (automated by `.github/workflows/release-rc.yml`).
- `vX.Y.Z` tags publish releases (GA tags only).
- Never push `hotfix/*` or `release/*` branches by hand; use `feature/` or `fix/`.
+3
View File
@@ -0,0 +1,3 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
+3
View File
@@ -0,0 +1,3 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
+6
View File
@@ -0,0 +1,6 @@
# Contributing to QBit Webhooks
* See Project [Contributing](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md)
* Branches follow GitFlow: `feature/*` from `develop`, pull requests to `develop`, `release/*` for release candidates, `vX.Y.Z` tags for releases.
* Commits are signed and follow Conventional Commits.
* Build and test locally with `./mvnw verify` (Java 21).
+1 -1
View File
@@ -176,7 +176,7 @@
END OF TERMS AND CONDITIONS
Copyright 2022-2025 QRunIO, LLC
Copyright 2024 Kingsrook, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -3
View File
@@ -1,6 +1,6 @@
qBit Template - Extension
Copyright 2022-2025 QRunIO, LLC
QBit Slack
Copyright 2026 Kingsrook, LLC
This product includes software developed at QRunIO, LLC (https://qrun.io/).
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
Licensed under the Apache License, Version 2.0.
+65 -28
View File
@@ -1,42 +1,79 @@
# QBit Template: Extension
# QBit Slack
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
Slack for a QQQ system. Users connect their own Slack accounts, and organization admins install the
app for their own workspace. The qbit registers a core messaging provider (`slack`) and a delivery
destination, so any process, scheduled job, report or export can send to a Slack DM or channel through
core alone. Every message is authorized as its owning user at send time, so the qbit is safe for
deployments with many tenants. Delivery uses a durable outbox with Slack rate-limit handling, and
classified PHI and PII never reach Slack.
## What is an Extension QBit?
Design: [docs/DESIGN.md](docs/DESIGN.md).
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
- New backend implementations
- Authentication mechanisms
- Audit capabilities
- Table customizers
- Action handlers
## Requirements
## Quick Start
- Java 21
- 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)
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
## Install
## Structure
```
src/main/java/com/kingsrook/qbits/example/
├── ExampleExtensionQBitConfig.java # Configuration
├── ExampleExtensionQBitProducer.java # Entry point
└── customizers/
└── ExampleTableCustomizer.java # Table customization
```java
new SlackQBitProducer()
.withQBitConfig(new SlackQBitConfig()
.withBackendName("rdbms")
.withSchedulerName("scheduler"))
.produce(qInstance);
```
## Key Characteristics
## Tables, permissions and configuration
- **No QAppSection** - Pure infrastructure
- **No user-facing tables** (or minimal operational)
- **Wraps existing tables/processes** with new behavior
- **Validates dependencies** on other QQQ components
See [docs/DESIGN.md](docs/DESIGN.md), sections 3 to 14. The tables are prefixed `slack_` and the
permissions `slack.`. Secrets are environment-variable names only: `SLACK_CLIENT_ID`,
`SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET` and `SLACK_TOKEN_KEY`.
## Documentation
## Setup and starter data
- [Getting Started](docs/00-getting-started.md)
- [Extension Patterns](docs/01-extension-patterns.md)
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
./mvnw verify
```
## License
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
+4
View File
@@ -0,0 +1,4 @@
# Security Policy
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
* Report suspected vulnerabilities privately through GitHub private vulnerability reporting on this repository, never in a public issue.
+11
View File
@@ -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>`.
+246
View File
@@ -0,0 +1,246 @@
<?xml version="1.0"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2022. 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.
-->
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
<!--
See reference at https://checkstyle.sourceforge.io/checks.html
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<!--
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
-->
<module name="TreeWalker">
<module name="SuppressWarningsHolder"/>
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="option" value="nl"/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
</module>
<!--
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
-->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="3"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="3"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="3"/>
<property name="arrayInitIndent" value="6"/>
</module>
<!--
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
-->
<module name="CustomImportOrder">
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
<property name="specialImportsRegExp" value="^javax\."/>
<property name="standardPackageRegExp" value="^java\."/>
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="false"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad"/>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="private"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="private"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
<module name="MissingOverride"/>
</module>
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
+19
View File
@@ -0,0 +1,19 @@
/*
* 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.
*/
-28
View File
@@ -1,28 +0,0 @@
# Getting Started
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
1. Choose **Use this template** on GitHub, then clone the repository you created.
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
5. Build the generated project:
```bash
mvn clean verify
```
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
## Register with a host application
The following uses the original example names; substitute the names chosen above:
```java
new ExampleExtensionQBitProducer()
.withConfig(new ExampleExtensionQBitConfig()
.withTargetTableName("order"))
.produce(qInstance, "my-extension");
```
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
-16
View File
@@ -1,16 +0,0 @@
# Extension Patterns
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
```java
QTableMetaData table = qInstance.getTable(tableName);
if(table == null)
{
throw new QException("Target table not found: " + tableName);
}
new ExampleTableCustomizer().customize(table);
```
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
+778
View File
@@ -0,0 +1,778 @@
# qbit-slack design
**Status:** approved design, built in phases (section 15).
## Owner requirements (verbatim)
- 2026-10-14: "like we have an email qbit, we need one for slack too, so other processes etc can send
messages to users via email or via slack etc etc"
- 2026-10-14: "we should be able to send messages to channels etc as output from scheduled jobs and scheduled
reports etc and from running things, etc or even as exports from the system (download is an option, but
so is slack, etc). Slack should be tied to the user logged in, as with some deployments, we have users
from MANY different companies logged in at once"
## Owner decisions (2026-10-14): all recommendations accepted (standing approval)
The owner approved all open designs on 2026-10-12 ("they are approved. we will adjust as needed once we
can view them working"). Every recommendation below is decided as written. The hard requirements from the
coordinator brief of 2026-10-14 are written into this design as requirements, not options (section 1).
**Superseded decision.** On 2026-10-13, qbit-alerts `docs/NOTIFICATIONS.md` section 1.1 recorded "Slack
stays a group or monitor channel only, never per user." The 2026-10-14 requirements replace it: Slack is a
per-user channel and a delivery destination. Alert-group and monitor Slack channels (incoming webhooks in
qbit-alerts) keep working unchanged.
## 0. Summary
qbit-slack connects QQQ to Slack **per user and per tenant**.
- **Connections are per user.** Each user connects their own Slack account through Slack OAuth ("Connect
Slack"). An organization admin may install the app's bot into their own workspace, and that install is
scoped to their organization (tenant) in QQQ. Tokens are encrypted at rest, write-only, and never shown,
logged or returned.
- **Messages are posted for a QQQ user (the owner).**
- They use that user's own connection, or their tenant's bot.
- They go only to workspaces the owner connected, and to channels the owner is a member of. Membership
is checked at send time.
- There is never a fallback to another user's or tenant's token.
- **Core messaging provider** (type `QBIT_SLACK`, name `slack`). Any process sends with core
`SendMessageAction`, so callers depend only on core.
- **Delivery destination** `slack`, through the new core output-delivery SPI (section 9). Process results,
durable jobs, scheduled reports and jobs, exports, saved views and assistant scheduled tasks can deliver
to a Slack DM or channel, next to Download and Email. Large outputs are uploaded as files, or sent as a
link.
- **Durable outbox** (`slack_message`). It retries, honours Slack rate limits (`Retry-After`), and
dedupes by idempotency key. Message metadata reconciliation stops a crashed runner from posting twice.
- **Readiness and health.** A public API package `SlackApi` mirrors `EmailApi`, and the qbit registers a
health check.
- **Notifications.** A Slack column in each user's notification preferences, so `QAlerts.notify(...)` can
reach them in Slack (section 10).
- **PHI.** Slack is not HIPAA-eligible by default, so classified PHI and PII are blocked or left out of
every Slack payload and export, with a clear message (section 11).
- **Phase 2: interactivity.** Buttons such as Approve and Acknowledge run QQQ actions as the clicking
user, with permissions enforced.
## 1. Hard requirements
| # | Requirement |
|---|---|
| R1 | Slack is tied to the signed-in user. Every outgoing message has an **owner** (a QQQ user id), and is authorized as that owner at send time. Ownerless sends exist only for system notices to a registered channel of a tenant's workspace (section 6.2). |
| R2 | Multi-tenant safe. Users of many companies share one deployment. Workspace installs belong to exactly one tenant (a QQQ security key value). Cross-tenant posting is impossible by construction, and is tested with two tenants and two workspaces. |
| R3 | A user posts only to workspaces they connected, and to channels they are a member of. At send time `conversations.members` (or `users.conversations`) must include the owner's Slack user id. |
| R4 | Scheduled and background deliveries run as the owning user, with that user's connection and permissions. A revoked or missing connection fails the delivery and alerts the owner. Delivery never falls back to another connection, user or tenant. |
| R5 | Tokens are encrypted (AES-256-GCM, bound to their row), write-only, never displayed, logged, audited or returned by any API. |
| R6 | Channel pickers show only what the current user's connection can see, as contingent dropdowns: workspace, then channel (or "Direct message to me"). |
| R7 | Classified PHI and PII are redacted or blocked for Slack, exports included. The user sees which fields and why. |
| R8 | Output delivery is a core concept with a thin SPI. Slack, Email and Download are destinations; object storage and SFTP come later. |
## 2. Flow
```
caller ─ SendMessageAction("slack") ─┐
caller ─ SlackApi.send / sendOnce ───┤
DeliverOutputAction ("slack") ───────┼─> SlackService (as the OWNER's session)
QAlerts.notify ─> qbit-alerts ───────┘ 1. resolve workspace under owner's tenant lock
2. resolve connection (owner) + target; membership check
3. render, PHI guard
4. insert slack_message (QUEUED, ownerUserId, workspaceId)
SlackDispatcher (scheduled) ─ claim + lease ─ re-authorize as owner ─ decrypt that row's token
└─ chat.postMessage / chat.update / files upload (with message metadata)
ok -> SENT · 429 -> wait Retry-After · transient -> RETRY_WAIT · permanent -> DEAD_LETTER
revoked/not a member -> FAILED_AUTH + QAlerts.notify(owner) (never another token)
```
## 3. Workspaces, connections and secrets
### 3.1 `slack_workspace` (Workspaces)
There is one row per Slack workspace install **per tenant**, unique (`teamId`, `tenantKey`). Two tenants that
use the same Slack workspace get two rows, each locked to its own tenant.
| Field | Notes |
|---|---|
| `teamId`, `teamName`, `enterpriseId`, `appId` | From `oauth.v2.access` |
| `tenantKey` | The tenant security value. It's the host's configured security field (for example `clientId` or `organizationId`), applied through `SlackQBitConfig.securityFields` and `recordSecurityLocks`, the same as qbit-email. Required when the host declares a tenant lock |
| `installedByUserId`, `installedAt` | |
| `botUserId`, `botScopes` | |
| `botToken` | Encrypted (`v1:<keyId>:<base64>`). It's write-only, and its tier is HIDDEN for every role (core field access). Set only by the OAuth callback; it can't be edited |
| `botTokenFingerprint` | The first 8 hex characters of SHA-256, for support ("ends …"). Not reversible |
| `status` | ACTIVE / REVOKED / UNINSTALLED / ERROR |
| `readinessStatus`, `readinessCheckedAt`, `readinessSummary` | Section 8 |
| `health`, `consecutiveFailures`, `ratePerMinute` | Section 5.3 |
Only the tenant's admins (`slack.admin`, within their tenant lock) see or change it.
### 3.2 `slack_connection` (Connections)
There is one row per QQQ user and workspace row, unique (`userId`, `workspaceId`). The row is owner-locked on
`userId`. Admins see their tenant's rows read-only, without tokens, and can revoke them.
| Field | Notes |
|---|---|
| `userId` | The QQQ user (owner lock) |
| `workspaceId` | The tenant-scoped workspace row |
| `slackUserId`, `slackUserName` | From OAuth (`authed_user.id`). This is the verified identity, so there's no email matching |
| `userToken` | Encrypted user token (`xoxp-`), present when user scopes were granted. Write-only and HIDDEN |
| `userTokenFingerprint`, `userScopes` | |
| `status` | ACTIVE / REVOKED / ERROR |
| `connectedAt`, `lastVerifiedAt`, `revokedAt`, `lastError` | `lastError` is scrubbed |
### 3.3 Connect Slack (OAuth v2)
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.
- 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.
2. **Callback.** `GET /qqq/hooks/slack/oauth/callback` is a qbit route through the route extension point,
like qbit-email's webhooks. It is a browser redirect from Slack.
- It requires a valid signed-in session whose user and tenant equal the `state`'s.
- It exchanges the code with `oauth.v2.access`, using the app's client id and secret from named
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.
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.
4. **Revocation from Slack** (phase 2, Events API): `tokens_revoked` and `app_uninstalled` mark the rows.
On any send, `invalid_auth`, `token_revoked` or `account_inactive` also mark them.
### 3.4 Token encryption
`SlackTokenCipher` is AES-256-GCM, with a random 96-bit nonce per value. The associated data is
`table:rowId:userId|tenantKey`, so a ciphertext copied to another row (or another tenant's row) doesn't
decrypt. The stored format is `v1:<keyId>:<base64(nonce|ct|tag)>`.
- **Keys** come from named environment variables, never from the database: `SLACK_TOKEN_KEY` (base64, 32
bytes), plus optional previous keys (`SLACK_TOKEN_KEY_PREVIOUS`) for rotation. This is the same model as
core `RefreshTokenCipher`.
- **Decryption** happens in the dispatcher immediately before the HTTP call. The plaintext is kept only on
the stack, and is never in a log, an exception, a table, an audit or an API response.
- **Scrubbing.** `SlackRedaction.scrub` removes `xox[abeprs]-…`, `Authorization` headers and secrets from
every error text.
- **Test.** A known fake token is driven through every failure path. The test asserts that it never reaches
a log line, a `lastError`, an exception message or an API projection.
App-level secrets are named environment variables, allow-listed in `allowedEnvNames`:
`SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET` and `SLACK_TOKEN_KEY`.
### 3.5 Slack app
`docs/slack-app-manifest.yaml` defines one distributable app, used by every tenant.
| Kind | Scopes |
|---|---|
| Bot | `chat:write`, `im:write`, `channels:read`, `groups:read`, `users:read`, `files:write`, `files:read` (file reconcile), `usergroups:read` (optional), `chat:write.customize` (optional) |
| User | `chat:write`, `channels:read`, `groups:read`, `im:write`, `files:write`, `files:read`, `users:read` |
- **Redirect URL:** `https://<host>/qqq/hooks/slack/oauth/callback`.
- **Interactivity URL (phase 2):** `https://<host>/qqq/hooks/slack/interactions`.
- **Distribution:** public distribution is on when tenants use their own workspaces. For a single company,
it's a normal workspace app.
## 4. Sending
### 4.1 Who posts, and the authorization check (R1-R4)
Every send carries `ownerUserId`. If it's missing, it's the current session user. System sessions must name
an owner, or use an ownerless channel (6.2).
**At queue time**, under the owner's session: the session is built with `createAutomatedSessionForUser`
when the caller is a system or job context, and the owner must equal the session user. Then:
1. Load the workspace **through a normal tenant-locked query** under the owner's session. A row of another
tenant is invisible, so it's REFUSED as "This Slack workspace isn't available to you".
2. Load the owner's ACTIVE connection in that workspace. If there isn't one, it's REFUSED with "Connect Slack
to <team>".
3. Resolve the target:
- **DM to the owner:** use the owner's Slack user id.
- **Channel:** check that the owner's Slack user id is in `conversations.members` for the channel, using
the workspace bot token, or the owner's user token when there's no bot. A result is cached for 10
minutes. If not, it's REFUSED with "You aren't a member of #x".
- **DM to another QQQ user** (system notices, 4.2): only when that user has an ACTIVE connection **in
the same workspace row**.
4. Pick the **posting identity**:
- **BOT** (the tenant's bot) when the workspace has an ACTIVE bot that's a channel member, or for DMs.
- **USER** (posts as the owner, with the owner's user token) otherwise, or when the caller asks for
`postAs(USER)`.
- There is never a different row's token.
**At send time** the dispatcher repeats steps 1 to 4 under a fresh session for the owner, because access may
have changed since the message was queued. Then it decrypts **only** the token of the chosen row.
- **Owner lost access:** the outcome is FAILED_AUTH, and the owner is notified through `QAlerts.notify` with
category SYSTEM: "Your Slack delivery to #x failed: <reason>. Reconnect Slack." The alert
`slack.delivery.unauthorized` is raised, deduplicated per owner per day.
- **The test** uses tenants A and B with workspaces WA and WB, a fake Slack server and a different token
for each. It checks all of these:
- A cannot target WB or WB's channels: the address, the picker, the API and the destination all refuse.
- A scheduled delivery of A, after A's connection is revoked, fails with an alert to A.
- The fake server never sees WB's token on A's traffic.
- Copying A's ciphertext into B's row fails to decrypt.
- A tenant admin of B cannot see A's connections or messages.
### 4.2 Core messaging provider (low coupling)
`SlackOutboxMessagingProvider implements MessagingProviderInterface`, type `QBIT_SLACK`, is registered under
`bridgeProviderName` (default `slack`).
| Address (`Party.address`) | Target |
|---|---|
| `me` | DM to the owner |
| `user:<qqqUserId>` | DM to that user through **their** connection in a workspace of the owner's tenant. Used for notifications, where owner and recipient are the same user (section 10) |
| `channel:<workspaceId>/<slackChannelId>` | A channel the owner is a member of (pickers produce this) |
| `channel:<name>` or `#<name>` | A registered channel (6.2) of the owner's tenant |
| `group:<name>` | A registered user group: a post with a mention, in its channel |
There is no addressing by email, and no raw Slack ids without a workspace. `MultiParty` gives one outbox row
per recipient. An unknown or unauthorized target is REFUSED with a reason, never an exception.
- **Capabilities** (core #1194): `TEMPLATES`, `IDEMPOTENCY`, `CATEGORIES`, `LINKED_RECORDS`, `ASYNC_DELIVERY`.
- **Output:** `QUEUED` with the id, `DUPLICATE` with the earlier id, or `REFUSED` with reasons.
For multiple recipients, `messageId` contains the accepted IDs (a list when more than one), and
idempotency keys are scoped by recipient. A partial refusal returns `REFUSED` with accepted IDs;
otherwise the status is `QUEUED` if any row is new, or `DUPLICATE` if all already existed.
- **Content:** `subject` becomes the bold first line and the notification text. Content items are mrkdwn,
escaped. A Block Kit JSON body needs `slack.send.blocks`. A template wins.
- **Dependency:** QQQ 4.1.0-RC.7. The bridge maps template name, variables, idempotency key,
category, linked record and sender name directly to the outbox. Locale remains advisory and is ignored.
### 4.3 Public API `com.kingsrook.qbits.slack.api` (mirrors `EmailApi`)
This is the only public surface. It's additive within a 0.x minor line.
| Method | Meaning |
|---|---|
| `isInstalled(QInstance)` | The qbit was produced |
| `send(SlackApiRequest)` | Queue: `SlackApiResult(status, messageId, reasons)` |
| `sendOnce(SlackApiRequest, String effectKey)` | One durable handoff keyed by the effect key. It refuses a caller transaction and returns `SlackApiEffectResult(QUEUED_OR_DUPLICATE or REFUSED, message)` after commit. This is the jobs_effect primitive |
| `reply(Integer parentMessageId, SlackApiRequest)` | A thread reply, which waits for the parent's `ts`. The same owner is required |
| `update(Integer messageId, SlackApiRequest)` | `chat.update` of a sent message. The same owner, or `slack.admin` in the tenant |
| `getMessage(id)`, `getMessageByIdempotencyKey(key)`, `findMessagesByIdempotencyKeyPrefix(prefix, limit)` | The metadata projection, never the body |
| `readiness(QInstance)` | `SlackApiReadiness(ready, checks)` for the deployment (app secrets and key) |
| `readinessFor(String userId)` | That user's view: connected workspaces, token health and scopes |
| `connectionsFor(String userId)` | `List<SlackApiConnection>` (workspace label, team, status). No tokens |
| `listTargets(String userId, Integer workspaceId)` | The channels that user can post to (picker data, R6) |
| `ensureTemplate(SlackApiTemplate)` | Seed a template once |
`SlackApiRequest` fields:
- `owner(userId)` and `to(...)`, using the 4.2 forms;
- `text`, `templateName`, `variables`, `idempotencyKey`, `category`;
- `linkedRecord(table, id)`, `senderName`, `threadOf(messageId)`;
- `postAs(BOT|USER)`, `attachment(...)` (9.3), `unfurl` (default off);
- `actions` (phase 2), `withTransaction(transaction)`.
### 4.4 Permissions
| Permission | Lets the holder |
|---|---|
| `slack.view` | Use the app and its tables, scoped by tenant and owner locks |
| `slack.connect` | Connect their own Slack (granted to ordinary users by default) |
| `slack.workspace.install` | Install the bot for their tenant |
| `slack.admin` | Administer workspaces, registered channels, groups and templates, and revoke connections, in their own tenant |
| `slack.send` | Queue through the provider, API or destination for themselves. Automated sessions for an owner use the owner's permissions |
| `slack.send.blocks` | Send raw Block Kit |
| `slack.message.body` | See message text |
## 5. Outbox and delivery
### 5.1 `slack_message` (Messages)
Each message is owner-locked on `ownerUserId` and tenant-locked through the workspace.
| Field | Notes |
|---|---|
| `ownerUserId`, `workspaceId`, `connectionId`, `postAs` | The authorization context (4.1) |
| `operation` | POST / UPDATE / FILE |
| `targetType`, `targetRef`, `slackChannelId`, `channelLabel` | `targetType`: ME, USER, CHANNEL, REGISTERED_CHANNEL, USER_GROUP |
| `status` | QUEUED, WAITING_PARENT, SENDING, RETRY_WAIT, SENT, DEAD_LETTER, FAILED_AUTH, REFUSED, SUPPRESSED, CANCELLED |
| `category`, `templateName`, `text`, `blocksJson`, `variablesSnapshot` | Rendered at queue time. `text` is masked without `slack.message.body` |
| `idempotencyKey` | Unique when present, up to 500 characters |
| `parentMessageId`, `threadTs`, `targetMessageId` | Threads and updates |
| `attachmentStorageRef`, `attachmentName`, `attachmentBytes`, `slackFileId` | For FILE messages |
| `slackTs`, `permalink` | |
| `attemptCount`, `nextAttemptDate`, `leaseExpiresDate`, `lastErrorCode`, `lastError`, `sentDate`, `deadLetterDate` | `lastError` is scrubbed |
| `linkedTableName`, `linkedRecordId`, `senderName`, `deliveryLogId` | `deliveryLogId` links to core `delivery_log` (9.2) |
The child `slack_message_attempt` (`messageId` + `attemptNumber` unique; outcome, error code, HTTP status,
`retryAfterSeconds`, the posting identity BOT or USER, claimedBy, and dates) shows as a relation.
### 5.2 Claim, lease and exactly-once posting
This is the qbit-email and qbit-webhooks pattern.
- **Claim and lease.** A runner inserts (`messageId`, *n*) into `slack_message_attempt` to claim attempt *n*.
The pair is unique, so one runner wins. The winner sets SENDING with a lease (`leaseSeconds`, default
120).
- **Our own posts are recognisable.** Every post carries Slack message metadata: `event_type: qqq_message`,
`event_payload: {id, key}`.
- **Expired leases.** Before retrying an expired SENDING row, the runner reads `conversations.history`
(`include_all_metadata`, from the claim time) in that channel, using the same row's token.
- If our id is there, the runner records that `ts` as SENT.
- If history can't be read, it waits and retries the read. It doesn't post blind.
- So a job retry or a dead pod never double-posts.
- For files, the upload's `external_id`-style title marker plays the same role.
- **Dedupe at queue time.** A repeated idempotency key returns DUPLICATE. A `sendOnce` replay returns
QUEUED_OR_DUPLICATE.
### 5.3 Retries, rate limits and health
| Slack response | Result |
|---|---|
| HTTP 429 | Wait `Retry-After` (capped at 15 minutes); it isn't an attempt. The workspace limiter pauses that method |
| HTTP 5xx, timeouts, network errors; `ratelimited`, `service_unavailable`, `internal_error`, `fatal_error`, `request_timeout` | Transient: RETRY_WAIT after `retryMinutes` (1, 5, 15, 60, 240, 720). DEAD_LETTER after `maxAttempts` (6) |
| `channel_not_found`, `is_archived`, `msg_too_long`, `invalid_blocks`, `no_text`, `user_not_found`, `cannot_dm_bot`, `user_disabled` | Permanent: DEAD_LETTER at once |
| `not_in_channel`, the owner no longer a member, `invalid_auth`, `token_revoked`, `account_inactive`, `missing_scope` | FAILED_AUTH: mark the connection or workspace, and notify the owner (4.1). Never retried with another token |
There's a client-side limiter per workspace row: at most one post per second per channel, and
`ratePerMinute` overall (default 50). Waiting under a limit isn't an attempt.
**Health:**
- After 10 consecutive transient failures a workspace is UNHEALTHY. While unhealthy, one message is tried
every 5 minutes.
- Optional alerts go through the core `QAlerts` producer: `slack.workspace.unhealthy`,
`slack.message.dead_letter` and `slack.delivery.unauthorized`. There's no dependency on qbit-alerts.
### 5.4 Record actions and retention
**Record actions** are standard streamed-ETL processes with Review and Result lines:
- **Resend** (DEAD_LETTER or FAILED_AUTH; re-authorized as the owner);
- **Cancel**;
- **Send Now**.
**Retention:** a daily job clears bodies and variables after `bodyRetentionDays` (30) once a message is
final. It deletes final rows after `messageRetentionDays` (0 means keep).
## 6. Channels, groups and templates
### 6.1 Live channels (pickers)
There's no channel table for user deliveries. Pickers and validation ask Slack, as the user:
- **The user token:** `users.conversations` (public and private channels the user is in);
- **Otherwise:** the bot's channel list filtered by `conversations.members` containing the user.
Results are cached for 5 minutes per connection, in memory only, with no disk. The picker shows
`#name (private)` with its workspace.
### 6.2 `slack_channel` (Registered Channels) and `slack_user_group` (User Groups)
These are tenant-scoped named targets for **system** and ownerless notices, for example monitors or
"#ops-alerts" for a tenant.
**`slack_channel`:**
- Fields: `name` (logical key, unique per workspace), `label`, `workspaceId`, `slackChannelId`,
`slackChannelName`, `isPrivate`, `botIsMember`, `isArchived`, `enabled`, `description`, `checkedAt`.
- The row needs the workspace's bot.
- A tenant admin who is a member of the channel registers it. The picker offers only their channels.
- Record actions: **Check channel** and **Send test message**.
- An ownerless send must come from a system session. It is still tenant-checked: the signal's tenant key must
equal the workspace's.
**`slack_user_group`:**
- Fields: `name`, `label`, `workspaceId`, `slackUsergroupId`, `handle`, `channelId`, `enabled`.
- A group target posts once, starting with `<!subteam^ID>`.
- There's no DM fan-out.
### 6.3 `slack_template` (Templates)
The variable model is shared with qbit-email:
- `{{name}}` and dotted paths;
- `{{#if}}` and `{{#each}}`;
- `{{name|fallback}}`;
- a declared variable schema (name, label, type, required, classification, and an optional bound table and
field), with the same rules as qbit-email section 5.2.
The engine is the qbit's own, with no dependency on qbit-email internals (G1).
| Field | Notes |
|---|---|
| `name`, `label`, `description`, `category`, `enabled`, `tenantKey` (optional; null means shared) | |
| `text` | mrkdwn. Values are escaped (`&`, `<`, `>`), so variables can't inject mentions or links |
| `blocksTemplate` | Optional Block Kit JSON, with variables in string positions only. Validated on save: structure, at most 50 blocks, under 40,000 characters |
| `variablesSchema` | JSON |
The built-in templates are:
- `notification`: severity emoji, title, body, a "View" button and a context line;
- `digest`;
- `delivery`: title, summary, link or file, source and run time.
A **Preview** widget renders sample variables to Block Kit and sends nothing.
## 7. Users without a connection
A user with no ACTIVE connection gets no Slack messages. Their preferences show **Connect Slack**. There's
no email-based linking, because in a multi-tenant deployment an email match in somebody else's workspace
proves nothing (R2). For a single-tenant deployment, `allowEmailLinking(true)` lets the tenant bot link a
user with `users.lookupByEmail`, but only when the IdP marked the session email verified. It's off by default.
## 8. Readiness and health
| Check | Scope | How |
|---|---|---|
| `app` | Deployment | The client id and secret, signing secret and token key are set (presence only). The token key decodes to 32 bytes |
| `workspace:<id>` | Tenant | `auth.test` on the bot: ok, `team_id` matches, scopes include the required set (optional ones are WARN) |
| `connection:<id>` | User | `auth.test` on the user token, run lazily (on send failure, and daily) |
| `registeredChannels` | Tenant | Each enabled registered channel exists, isn't archived, and the bot is a member |
| `outbox` | Deployment | No DEAD_LETTER in the last hour, and the oldest QUEUED is younger than 10 minutes |
- **Where results show:** stored on the rows and shown on the home page. A tenant admin sees their tenant's
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.
- Tenant workspace problems are details, not a pod-down signal.
- The payload follows the health program's conventions; qbit-email's check is the pattern.
## 9. Output delivery (core SPI, Slack destination)
The core part is specified in QRun-IO/qqq `docs/specs/OUTPUT-DELIVERY.md` (its own PR). Summary:
### 9.1 Core (thin, generic)
- **`DeliveryDestinationInterface`**, in core package `delivery`:
- `getType()`;
- `describeTargets(context, chosenValues)`: the next contingent picker field with its options, as the
current user;
- `validate(request)`;
- `deliver(request)`, returning `DeliveryResult`.
- **Metadata.** `QDeliveryDestinationMetaData` (name, label, icon, provider code reference,
`maxClassification`, `maxAttachmentBytes`, `acceptsLinks`) is registered in the supplemental
`DeliveryDestinationsMetaData`.
- **`DeliveryRequest`** carries:
- `destinationName`, `targetValues`, `ownerUserId`;
- `sourceType` (PROCESS / JOB / SCHEDULED_REPORT / SCHEDULED_JOB / EXPORT / SAVED_VIEW / ASSISTANT_TASK)
and `sourceKey`;
- `title`, `summary` (Markdown), `link`;
- `attachments` (name, MIME type, size, storage table and reference; streamed, no filesystem);
- `contentClassification` (the highest field classification in the payload) and `restrictedFields`
(their labels);
- `idempotencyKey`.
- **`DeliverOutputAction`** runs as the owner. It:
1. checks permissions;
2. refuses content above the destination's `maxClassification`, naming the fields;
3. turns an over-size attachment into a link (an authenticated app link, or a presigned storage URL only
for content below PII);
4. writes `delivery_log`;
5. calls the destination.
- **`delivery_log`** (core table, optional provider): one row per delivery and attempt, with owner, source,
destination, a target summary (never secrets), status, attempts, external reference, error, and a unique
idempotency key. Durable jobs deliver through the `jobs_effect` outbox, so it's exactly once.
- **`output_delivery`** (core table): saved delivery choices attached to a scheduled report, scheduled job
or saved view. It's owner-locked.
- **Built-in destinations:** DOWNLOAD (today's behaviour), and EMAIL through core `SendMessageAction`.
qbit-email can register a richer one.
- **UI API:** `GET /qqq/v1/delivery/destinations` and
`POST /qqq/v1/delivery/destinations/{name}/targets` (contingent options).
### 9.2 Slack destination (this qbit)
`SlackDeliveryDestination`, name `slack`, `maxClassification` CONFIDENTIAL. That means PII and PHI are refused,
or left out (R7).
- **Picker:**
1. **Workspace:** the current user's ACTIVE connections, tenant-filtered.
2. **Where:** "Direct message to me", then the channels from 6.1.
- **Deliver:** queues a `slack_message` for the owner, with the `delivery` template.
- Attachments use the files upload flow: `files.getUploadURLExternal`, a streamed PUT from storage, then
`files.completeUploadExternal` into the channel or DM.
- Over `maxAttachmentBytes` (default 50 MB), the attachment becomes a link (9.1).
- The `delivery_log` id is stored on the message, and the message status maps back to the log row
(QUEUED, then DELIVERED, FAILED or FAILED_AUTH).
- **Scheduled deliveries** (`output_delivery` rows) store the workspace row id and channel id, never a
token. Each run re-authorizes as the owner (4.1, R4).
### 9.3 Exports and PHI (R7)
For an export or report delivered to Slack, the producer computes `contentClassification` from the
exported columns.
- **Above CONFIDENTIAL:** the user sees "This export includes restricted fields (Patient name, Date of birth).
Slack can't receive them. Leave them out, or choose Download or Email."
- **"Leave out restricted columns"** is the option, saved on the delivery. Core re-renders the export without
them.
- **Links:** a presigned URL is never used for PII or PHI. An authenticated app link is.
## 10. Unified "notify a user" routing
**Decision:** the one call stays core `QAlerts.notify(NotificationSignal)` with an audience and a
`NotificationCategory`. It's in core already (RC.6) and is the thin contract. **The fan-out lives in
qbit-alerts**, which owns the per-category preferences, recipients, quiet hours and digests. Building it in
core would create a second preference store.
| Piece | Where | Change |
|---|---|---|
| Producer contract | core `QAlerts.notify` | None |
| Transport | core `SendMessageAction` provider `slack`, address `user:<id>`, owner = recipient | No qbit-slack import (G1) |
| Preferences | `alerts_notification_preference` | Add `slackMode` (OFF / IMMEDIATE / DAILY_DIGEST, default OFF) and `slackMinSeverity` (default WARNING). SECURITY gets the title and link only |
| Quiet hours | `alerts_user_preference` | Apply to email and Slack alike. CRITICAL bypasses them |
| Digest | Digest job | Also one Slack DM per user at the digest hour (`digest` template) |
| Availability | qbit-alerts | The Slack column shows when `SendMessageAction.isAvailable("slack")`. For a user without a connection it reads "Connect Slack first" |
| Recipient state | `alerts_notification_recipient` | Add `slackStatus` and `slackMessageId` |
| Idempotency | qbit-alerts | `notify:<notificationId>:<userId>:slack` |
The DM goes through the recipient's own connection and their tenant's bot (4.1), so a user's notifications
never travel through another tenant's install.
qbit-slack's own Preferences section (core #1162) has:
- the list of Connections, read-only;
- the actions **Connect Slack**, **Disconnect** and **Send me a test message**;
- with `slack.workspace.install`, **Install for my organization**.
The section requires `slack.connect`, appears in the user menu, and rejects PATCH requests.
Actions are filtered per session; test messages also require `slack.send`. RC.7 has no action-level
permission property, so the section returns a filtered metadata view without mutating its shared
registrations. Connect and Install return their authorize URL in `UserPreferenceActionResult.message`;
RC.7 has no external-link or redirect result field, so this does not automatically navigate the client.
Without qbit-alerts, callers use `SendMessageAction` (`slack`, `me` or `user:<id>`) directly.
## 11. PHI and PII
Slack is not HIPAA-eligible on standard plans, so every Slack payload is non-PHI.
1. **Template variables** classified PHI or PII (declared, or from a bound field through core
`ClassifiedValues`) are refused when the template is saved. At send time, such a value becomes its
`ClassifiedValueTokens` token if the variable allows `tokenize`. Otherwise the send is REFUSED: "Variable
x is classified PHI and can't be sent to Slack."
2. **Linked records** become a "View" link into the app, where normal permissions apply. Field values are
never copied.
3. **Free text:** `SlackPayloadGuard` refuses values the caller marked classified, and `phiPatterns` matches
(default SSN and MRN-style). It refuses rather than silently redacting.
4. **Exports and reports** follow 9.3. Attachments are checked through `contentClassification`, never by
reading their bytes.
5. **SECURITY** category: title and link only.
6. **Storage:** `slack_message` is classified PII (owner, channel). Tokens are encrypted and HIDDEN.
7. **Canary test:** a fixture table has a PHI field holding a unique canary string. The test sends it
through every path: template variable, linked record, alerts body, update, and export delivery. It
asserts that the canary appears in no table column, no fake-Slack HTTP body or upload, and no log line.
## 12. Interactivity (phase 2)
- **Route:** `POST /qqq/hooks/slack/interactions`, verified with the signing secret.
- `X-Slack-Signature` must equal `v0=` + HMAC-SHA256 over `v0:{ts}:{raw body}`.
- The timestamp must be within 5 minutes, and the comparison is constant-time.
- A failure gets 401 and is logged as rejected, without the body.
- **Buttons:** `SlackApiRequest.withAction(...)` names a registered `SlackActionMetaData`, which has:
- `name` and `label`;
- `style` and an optional `confirm` dialog;
- `processName` and `permissionName`.
The button value is an opaque token that the server signs over the message id, action, table, record id
and expiry.
- **Execution:**
1. Acknowledge within 3 seconds, then queue the work.
2. Map (`team_id`, Slack `user.id`) to an ACTIVE `slack_connection` in a workspace row **of the message's
workspace row**, then to the QQQ user. An unconnected user gets an ephemeral "Connect Slack in
Preferences".
3. Run the process under that user's automated session, through the normal process path, so permissions,
tenant locks and record locks apply. Denied gets an ephemeral "You don't have permission to Approve".
4. On success, `chat.update` (an UPDATE row) adds "Approved by Ada at 14:02".
- **Audit:** `slack_interaction` (Interactions) records workspace, Slack user, QQQ user, message, action,
outcome (DONE / DENIED / FAILED / REJECTED / EXPIRED), error and dates. It's a child list on the message.
- **Built-in actions:** `acknowledge_alert` and `resolve_alert`, by process name. `approve` and `reject` are
for apps to register.
- **Revocation events:** `tokens_revoked` and `app_uninstalled` arrive through the Events API at
`/qqq/hooks/slack/events`.
- **Later:** slash commands and the App Home tab.
## 13. App and UI
The UI goes through the `/qqq/v1` UI API only.
- **App:** `slack`, labelled "Slack".
- **Home (the only dashboard):**
- For a user: their connections and recent deliveries.
- For a tenant admin: workspace readiness with fix hints, counts (queued, retrying, sent in 24 h,
dead letters and auth failures in 24 h), and recent failures linked to their records.
- **Nav:** Messages, Connections, Workspaces, Registered Channels, User Groups, Templates.
- **Relations:** attempts and interactions on a message. Connections, channels and groups as child lists
on the workspace.
- **Record actions** have plain labels and never appear in the nav:
- Check readiness, Check channel and Send test message;
- Disconnect and Revoke;
- Resend, Cancel and Send Now.
- **Tables:** all are standard visible tables with record screens, the API (matching the admin), audit,
ESB events, and Quick Search on Messages and Connections. They're secured by permissions, owner locks
and tenant locks.
- **Layout:** readable at every width, light and dark.
## 14. Data model and configuration
### 14.1 Tables
| Table | Label | Lock | Nav |
|---|---|---|---|
| `slack_workspace` | Workspaces | Tenant | Yes |
| `slack_connection` | Connections | Owner (`userId`) and tenant through the workspace | Yes |
| `slack_channel` | Registered Channels | Tenant through the workspace | Yes |
| `slack_user_group` | User Groups | Tenant through the workspace | Yes |
| `slack_template` | Templates | Tenant (null means shared) | Yes |
| `slack_message` | Messages | Owner and tenant | Yes |
| `slack_message_attempt` | Message Attempts | Through the message | Relation |
| `slack_interaction` | Interactions | Through the message | Relation (phase 2) |
DDL is `docs/ddl/slack-postgres.sql`. kof22 applies it as a Liquibase changeset.
### 14.2 `SlackQBitConfig` settings
| Setting | Default |
|---|---|
| `backendName` | Required |
| `schedulerName` | Required |
| `bridgeProviderName` | `slack` |
| `destinationName` | `slack` |
| `secretResolver` | `System::getenv` |
| `allowedEnvNames` | The five names in 3.4 |
| `slackApiBaseUrl` | Overridden in tests |
| `publicBaseUrl` | |
| `oauthCallbackPath` | `/qqq/hooks/slack/oauth/callback` |
| `securityFields` | The tenant key field |
| `recordSecurityLocks` | |
| `tableMetaDataCustomizer` | |
| `dispatchIntervalSeconds` | 10 |
| `batchSize` | 50 |
| `maxAttempts` | 6 |
| `retryMinutes` | 1, 5, 15, 60, 240, 720 |
| `leaseSeconds` | 120 |
| `bodyRetentionDays` | 30 |
| `messageRetentionDays` | 0 |
| `readinessIntervalMinutes` | 15 |
| `membershipCacheMinutes` | 10 |
| `maxAttachmentBytes` | 50 MB |
| `allowEmailLinking` | false |
| `phiPatterns` | |
| `appName` | `slack` |
| `appLabel` | `Slack` |
## 15. Phases and acceptance
**Phase 1a: spec and scaffold (this PR).**
- The repo standard is met.
- `./mvnw verify` passes.
**Phase 1b: tables, cipher, outbox, dispatcher and provider.**
- Tables, owner and tenant locks, `SlackTokenCipher` and `SlackRedaction`.
- Queue-time and send-time authorization (4.1).
- Claim, lease and reconcile; 429, transient and permanent handling; FAILED_AUTH with the owner alert.
- The core provider under `slack`.
- Fake-Slack-server tests:
- 429 with `Retry-After`;
- the crash and reconcile case, which posts once;
- duplicate keys;
- a thread waiting for its parent;
- an update keeping the same ts;
- a revoked connection;
- **two tenants and two workspaces** (4.1);
- token non-leakage.
**Phase 1c: OAuth connect, `SlackApi`, readiness and health.**
- Connect, Disconnect and Install through the signed state and callback route.
- The API surface (4.3).
- Readiness checks against the fake server (good, missing scope, revoked, bot not in channel).
- Health registration.
**Phase 1d: templates, the PHI guard and the canary test.**
**Phase 1e: delivery destinations.**
- The core PR: SPI, `DeliverOutputAction`, `delivery_log`, `output_delivery`, the DOWNLOAD destination and
the UI API endpoints.
- The qbit's `SlackDeliveryDestination`, with contingent pickers and file upload.
- The qbit side builds on a `STAND-IN(core=#N, removeBy=RC.N)` copy until core releases.
- Tests: picker isolation, a PHI export refused, then delivered with the restricted columns left out, and
over-size becoming a link.
**Phase 1f: app, home and preferences section.**
- 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.
**Phase 1h: kof22 adoption.** A draft Website-Backend PR, plus the Praetor Infisical PR (section 17).
**Phase 2: interactivity and the Events API.**
- Tests:
- a bad signature or a stale timestamp gives 401;
- a forged value is refused;
- an unconnected user is prompted;
- a user without permission is DENIED;
- a Slack user of another workspace row cannot act on this message;
- Approve runs and updates the message.
## 16. Decisions (all recommendations accepted)
1. The qbit is a core messaging provider and a core delivery destination. Callers use only core.
2. Connections are per user, through OAuth. Bot installs are per tenant. Tokens are encrypted, write-only
and HIDDEN, and the keys come from environment variables.
3. Every message has an owner. Authorization runs at queue time and again at send time. There is never a
token fallback.
4. Membership is checked with `conversations.members` at send time, cached for 10 minutes.
5. There's no email-based linking by default. It's an opt-in for single-tenant deployments, with an
IdP-verified email.
6. The outbox posts exactly once, using message-metadata reconciliation.
7. The template variable model is shared with qbit-email, with the qbit's own engine.
8. Routing goes through core `QAlerts.notify`, with the fan-out in qbit-alerts. This supersedes the
2026-10-13 "never per user" decision.
9. Core output delivery is a thin SPI plus a log, owned by core, and specified in qqq.
10. No PHI or PII goes to Slack. Exports are blocked, or sent without the restricted columns. There's a
canary test.
11. User groups are mentions in a registered channel.
12. kof22 adopts through a draft PR. The secrets are seeded write-only in Infisical.
## 17. Owner steps (outside code)
1. **Workspace.** Use the kof22 Slack workspace, or create one (slack.com/get-started). You need to be a
workspace admin.
2. **App.**
1. At api.slack.com/apps, choose **Create New App**, then **From a manifest**.
2. Pick the workspace and paste `docs/slack-app-manifest.yaml`, after replacing `<host>` with
`admin.kof22.com`.
3. Create the app. Leave public distribution off for kof22, which is single-tenant.
3. **Credentials.** From **Basic Information**, copy the **Client ID**, **Client Secret** and **Signing
Secret**. Generate the token key with `openssl rand -base64 32`. Don't paste any of them into chat, an
issue or a commit. You don't need to copy a bot token: it's created when you click **Connect Slack**,
then **Install for my organization**, in kof22 Preferences.
4. **Infisical.** A Praetor PR adds the folder `/kof22-website-slack` in the `k8s-galaxy` project, following
the `kof22-webhooks.tf` pattern: write-only, ephemeral variables, `value_wo`. The keys are `client-id`,
`client-secret`, `signing-secret` and `token-key`. Seed only those resources:
```bash
TF_VAR_kof22_slack_client_id='…' TF_VAR_kof22_slack_client_secret='…' \
TF_VAR_kof22_slack_signing_secret='…' TF_VAR_kof22_slack_token_key="$(openssl rand -base64 32)" \
tofu apply -target=infisical_secret_folder.kof22_website_slack \
-target=infisical_secret.kof22_slack_client_id \
-target=infisical_secret.kof22_slack_client_secret \
-target=infisical_secret.kof22_slack_signing_secret \
-target=infisical_secret.kof22_slack_token_key
```
The token key has no other copy. Losing it means every user reconnects.
5. **cluster-gitops.** An ExternalSecret maps the keys to `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`,
`SLACK_SIGNING_SECRET` and `SLACK_TOKEN_KEY` in `kof22-website-prod`. The coordinator sequences it with
the Website-Backend PR.
6. **First use.** In kof22, open Preferences, then Slack, then **Install for my organization**, and approve
in Slack. Invite the app to any channel the bot should post in (`/invite @<app>`).
+187
View File
@@ -0,0 +1,187 @@
-- Phase 1b: slack_workspace, slack_connection, slack_channel, slack_user_group, slack_template,
-- slack_message and slack_message_attempt. Apply before producing the qbit.
-- Host tenant (security) columns, configured through SlackQBitConfig.securityFields, must be
-- added by the host to slack_workspace and slack_template (children inherit them by join).
-- bot_token and user_token hold AES-256-GCM ciphertext (v1:keyId:base64), never plaintext.
CREATE TABLE slack_workspace (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
team_id VARCHAR(500) NOT NULL,
team_name VARCHAR(500),
enterprise_id VARCHAR(500),
app_id VARCHAR(500),
tenant_key VARCHAR(500),
installed_by_user_id VARCHAR(500),
installed_at TIMESTAMP,
bot_user_id VARCHAR(500),
bot_scopes TEXT,
bot_token VARCHAR(2000),
bot_token_fingerprint VARCHAR(500),
status VARCHAR(500),
readiness_status VARCHAR(500),
readiness_checked_at TIMESTAMP,
readiness_summary VARCHAR(500),
health VARCHAR(500),
consecutive_failures INTEGER,
rate_per_minute INTEGER,
UNIQUE (team_id, tenant_key)
);
CREATE TABLE slack_connection (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
user_id VARCHAR(500) NOT NULL,
workspace_id INTEGER NOT NULL,
slack_user_id VARCHAR(500),
slack_user_name VARCHAR(500),
user_token VARCHAR(2000),
user_token_fingerprint VARCHAR(500),
user_scopes TEXT,
status VARCHAR(500),
connected_at TIMESTAMP,
last_verified_at TIMESTAMP,
revoked_at TIMESTAMP,
last_error VARCHAR(500),
UNIQUE (user_id, workspace_id)
);
CREATE TABLE slack_channel (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
name VARCHAR(500) NOT NULL,
label VARCHAR(500),
workspace_id INTEGER NOT NULL,
slack_channel_id VARCHAR(500),
slack_channel_name VARCHAR(500),
is_private BOOLEAN,
bot_is_member BOOLEAN,
is_archived BOOLEAN,
enabled BOOLEAN,
description TEXT,
checked_at TIMESTAMP,
UNIQUE (workspace_id, name)
);
CREATE TABLE slack_user_group (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
name VARCHAR(500) NOT NULL,
label VARCHAR(500),
workspace_id INTEGER NOT NULL,
slack_usergroup_id VARCHAR(500),
handle VARCHAR(500),
channel_id VARCHAR(500),
enabled BOOLEAN,
UNIQUE (workspace_id, name)
);
CREATE TABLE slack_template (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
name VARCHAR(500) NOT NULL,
label VARCHAR(500),
description TEXT,
category VARCHAR(500),
enabled BOOLEAN,
tenant_key VARCHAR(500),
text TEXT,
blocks_template TEXT,
variables_schema TEXT,
UNIQUE (name)
);
CREATE TABLE slack_message (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
owner_user_id VARCHAR(500) NOT NULL,
workspace_id INTEGER NOT NULL,
connection_id INTEGER,
post_as VARCHAR(500),
operation VARCHAR(500),
target_type VARCHAR(500),
target_ref VARCHAR(500),
slack_channel_id VARCHAR(500),
channel_label VARCHAR(500),
status VARCHAR(500),
category VARCHAR(500),
template_name VARCHAR(500),
text TEXT,
blocks_json TEXT,
variables_snapshot TEXT,
idempotency_key VARCHAR(500),
parent_message_id INTEGER,
thread_ts VARCHAR(500),
target_message_id INTEGER,
attachment_storage_ref VARCHAR(500),
attachment_storage_table VARCHAR(500),
file_reconcile_required BOOLEAN DEFAULT FALSE,
attachment_name VARCHAR(500),
attachment_bytes BIGINT,
slack_file_id VARCHAR(500),
slack_ts VARCHAR(500),
permalink TEXT,
attempt_count INTEGER,
next_attempt_date TIMESTAMP,
lease_expires_date TIMESTAMP,
last_error_code VARCHAR(500),
last_error VARCHAR(500),
sent_date TIMESTAMP,
dead_letter_date TIMESTAMP,
linked_table_name VARCHAR(500),
linked_record_id VARCHAR(500),
sender_name VARCHAR(500),
delivery_log_id VARCHAR(500),
UNIQUE (idempotency_key)
);
CREATE TABLE slack_message_attempt (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
message_id INTEGER NOT NULL,
attempt_number INTEGER NOT NULL,
outcome VARCHAR(500),
error_code VARCHAR(500),
http_status INTEGER,
retry_after_seconds INTEGER,
posted_as VARCHAR(500),
claimed_by VARCHAR(500),
start_date TIMESTAMP,
end_date TIMESTAMP,
error VARCHAR(500),
UNIQUE (message_id, attempt_number)
);
CREATE INDEX slack_message_due ON slack_message (status, next_attempt_date);
CREATE INDEX slack_message_owner ON slack_message (owner_user_id, status);
CREATE INDEX slack_message_workspace ON slack_message (workspace_id);
CREATE INDEX slack_message_parent ON slack_message (parent_message_id);
CREATE INDEX slack_connection_workspace ON slack_connection (workspace_id);
CREATE INDEX slack_channel_workspace ON slack_channel (workspace_id);
CREATE INDEX slack_user_group_workspace ON slack_user_group (workspace_id);
CREATE TABLE slack_interaction (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
create_date TIMESTAMP,
modify_date TIMESTAMP,
workspace_id INTEGER,
message_id INTEGER,
team_id VARCHAR(500),
slack_user_id VARCHAR(500),
user_id VARCHAR(500),
action_name VARCHAR(500),
delivery_key VARCHAR(500),
outcome VARCHAR(500),
error VARCHAR(500),
completed_date TIMESTAMP,
UNIQUE (delivery_key)
);
CREATE INDEX slack_interaction_message ON slack_interaction (message_id);
CREATE INDEX slack_interaction_workspace ON slack_interaction (workspace_id);
+43
View File
@@ -0,0 +1,43 @@
# Slack app manifest for qbit-slack (docs/DESIGN.md section 3.5).
# Replace <host> with the admin host (for kof22: admin.kof22.com) before creating the app.
display_information:
name: QQQ
description: Messages, reports and exports from your QQQ apps.
features:
bot_user:
display_name: QQQ
always_online: false
oauth_config:
redirect_urls:
- https://<host>/qqq/hooks/slack/oauth/callback
scopes:
bot:
- chat:write
- im:write
- channels:read
- groups:read
- users:read
- files:write
- files:read
- usergroups:read
- chat:write.customize
user:
- chat:write
- channels:read
- groups:read
- im:write
- files:write
- files:read
- users:read
settings:
interactivity:
is_enabled: true
request_url: https://<host>/qqq/hooks/slack/interactions
event_subscriptions:
request_url: https://<host>/qqq/hooks/slack/events
bot_events:
- app_uninstalled
- tokens_revoked
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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
#
# http://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.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+222 -21
View File
@@ -1,36 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<modelVersion>4.0.0</modelVersion>
<!--
~ 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.
-->
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-example-extension</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>QBit Example Extension</name>
<description>Example Extension QBit - rename this for your QBit</description>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.0.0</qqq.version>
<jacoco.version>0.8.15</jacoco.version>
<h2.version>2.2.224</h2.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-slack</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QBit Slack</name>
<description>QBit for Slack across a QQQ system: per-user and per-tenant Slack connections, a core messaging provider and delivery destination, a durable outbox with rate-limit handling, Block Kit templates, readiness and PHI-safe payloads</description>
<url>https://github.com/QRun-IO/qbit-slack</url>
<modelVersion>4.0.0</modelVersion>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qbit-slack.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-slack.git</developerConnection>
<url>https://github.com/QRun-IO/qbit-slack</url>
<tag>HEAD</tag>
</scm>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.19</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-health</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>${qqq.version}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -42,17 +120,140 @@
</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-compiler-plugin</artifactId>
<version>3.11.0</version>
<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-surefire-plugin</artifactId>
<version>3.1.2</version>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.3.1</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>10.12.6</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<headerLocation>checkstyle/license.txt</headerLocation>
<encoding>UTF-8</encoding>
<consoleOutput>true</consoleOutput>
<failsOnError>false</failsOnError>
<failOnViolation>true</failOnViolation>
<violationSeverity>warning</violationSeverity>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>validate</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<configuration>
<excludes>
<exclude>com/kingsrook/qbits/slack/demo/**</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
<profiles>
<!-- QQQ 4.1 APIs until qbit-build-parent imports the 4.1 BOM (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</profile>
</profiles>
</project>
@@ -1,75 +0,0 @@
/*******************************************************************************
** Configuration for the Example Extension QBit.
*******************************************************************************/
package com.kingsrook.qbits.example;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
/*******************************************************************************
** Configuration class for the Example Extension QBit.
*******************************************************************************/
public class ExampleExtensionQBitConfig implements QBitConfig
{
private String targetTableName;
private Boolean enableFeatureX = true;
/*******************************************************************************
** Validate configuration before QBit is produced.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, List<String> errors)
{
if(targetTableName != null && qInstance.getTable(targetTableName) == null)
{
errors.add("Target table not found: " + targetTableName);
}
}
/*******************************************************************************
** Getter for targetTableName
*******************************************************************************/
public String getTargetTableName()
{
return targetTableName;
}
/*******************************************************************************
** Fluent setter for targetTableName
*******************************************************************************/
public ExampleExtensionQBitConfig withTargetTableName(String targetTableName)
{
this.targetTableName = targetTableName;
return this;
}
/*******************************************************************************
** Getter for enableFeatureX
*******************************************************************************/
public Boolean getEnableFeatureX()
{
return enableFeatureX;
}
/*******************************************************************************
** Fluent setter for enableFeatureX
*******************************************************************************/
public ExampleExtensionQBitConfig withEnableFeatureX(Boolean enableFeatureX)
{
this.enableFeatureX = enableFeatureX;
return this;
}
}
@@ -1,66 +0,0 @@
/*******************************************************************************
** Producer for the Example Extension QBit.
*******************************************************************************/
package com.kingsrook.qbits.example;
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.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
/*******************************************************************************
** Producer class for the Example Extension QBit.
*******************************************************************************/
public class ExampleExtensionQBitProducer implements QBitProducer
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-example-extension";
public static final String VERSION = "0.1.0";
private ExampleExtensionQBitConfig config;
/*******************************************************************************
** Produce this QBit into the given QInstance.
*******************************************************************************/
@Override
public void produce(QInstance qInstance, String namespace) throws QException
{
QBitMetaData qBitMetaData = new QBitMetaData()
.withGroupId(GROUP_ID)
.withArtifactId(ARTIFACT_ID)
.withVersion(VERSION)
.withNamespace(namespace)
.withConfig(config);
qInstance.addQBit(qBitMetaData);
if(config != null && config.getTargetTableName() != null)
{
applyTableCustomizations(qInstance, config.getTargetTableName());
}
}
/*******************************************************************************
** Apply customizations to the target table.
*******************************************************************************/
private void applyTableCustomizations(QInstance qInstance, String tableName)
{
// Implement your extension logic here
}
/*******************************************************************************
** Fluent setter for config
*******************************************************************************/
public ExampleExtensionQBitProducer withConfig(ExampleExtensionQBitConfig config)
{
this.config = config;
return this;
}
}
@@ -1,23 +0,0 @@
/*******************************************************************************
** Example table customizer.
*******************************************************************************/
package com.kingsrook.qbits.example.customizers;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Example table customizer that adds behavior to existing tables.
*******************************************************************************/
public class ExampleTableCustomizer
{
/*******************************************************************************
** Apply customizations to the given table.
*******************************************************************************/
public void customize(QTableMetaData table)
{
// Example: Add audit tracking fields
// table.withField(new QFieldMetaData("createdAt", QFieldType.DATE_TIME));
}
}
@@ -0,0 +1,96 @@
/*
* 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.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Opt-in host ESB, API and Quick Search wiring, with explicit safe projections.
** Hosts must retain the table permissions and record locks in each integration.
*******************************************************************************/
public final class SlackIntegrations
{
/*******************************************************************************
** Quick Search metadata contains no message body or credentials.
*******************************************************************************/
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackIntegrations()
{
}
/*******************************************************************************
** Main tables publish insert, update and delete events with safe fields.
*******************************************************************************/
public static List<String> esbTableNames()
{
return (SlackTables.MAIN);
}
/*******************************************************************************
** Main tables exposed under normal viewer permissions.
*******************************************************************************/
public static List<String> apiTableNames()
{
return (SlackTables.MAIN);
}
/*******************************************************************************
** Explicitly exclude both token columns, even if a host unhides them.
*******************************************************************************/
public static List<String> apiFieldNames(QInstance instance, String table)
{
if(!SlackTables.MAIN.contains(table))
{
return (List.of());
}
return (instance.getTable(table).getFields().keySet().stream()
.filter(name -> !SlackTables.BOT_TOKEN.equals(name) && !SlackTables.USER_TOKEN.equals(name))
.filter(name -> !name.toLowerCase(java.util.Locale.ROOT).contains("token"))
.filter(name -> !instance.getTable(table).getField(name).getIsHidden()).toList());
}
/*******************************************************************************
** ESB must not broadcast credentials or permission-protected message bodies.
*******************************************************************************/
public static List<String> esbFieldNames(QInstance instance, String table)
{
return (apiFieldNames(instance, table).stream().filter(name -> !List.of("text", "blocksJson", "variablesSnapshot").contains(name)).toList());
}
/*******************************************************************************
** Safe, viewer-scoped search on messages and connections.
*******************************************************************************/
public static List<SearchableTable> quickSearchTables()
{
return (List.of(
new SearchableTable(SlackTables.MESSAGE, Map.of("id", 10, "status", 4, "channelLabel", 8), "modifyDate", "%s", List.of("id")),
new SearchableTable(SlackTables.CONNECTION, Map.of("slackUserName", 10, "status", 4), "modifyDate", "%s", List.of("slackUserName"))));
}
}
@@ -0,0 +1,801 @@
/*
* 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.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Configuration for the Slack qbit (docs/DESIGN.md section 14.2). Settings are
** added phase by phase; secrets are only ever environment-variable names.
*******************************************************************************/
public class SlackQBitConfig implements QBitConfig
{
private final java.util.Map<String, com.kingsrook.qbits.slack.api.SlackActionMetaData> actions = new java.util.LinkedHashMap<>();
/*******************************************************************************
** Register a server-owned action. Approve and reject are application-defined.
*******************************************************************************/
public SlackQBitConfig withAction(com.kingsrook.qbits.slack.api.SlackActionMetaData action)
{
if(action == null || action.getName() == null || !action.getName().matches("[a-zA-Z][a-zA-Z0-9_]{0,79}")
|| action.getLabel() == null || action.getLabel().isBlank() || action.getLabel().length() > 75
|| action.getProcessName() == null || action.getPermissionName() == null
|| action.getStyle() != null && !List.of("primary", "danger").contains(action.getStyle())
|| (action.getConfirmTitle() == null) != (action.getConfirmText() == null))
{
throw (new IllegalArgumentException("Invalid Slack action metadata"));
}
actions.put(action.getName(), action);
return (this);
}
/*******************************************************************************
** Registered actions keyed by name.
*******************************************************************************/
public java.util.Map<String, com.kingsrook.qbits.slack.api.SlackActionMetaData> getActions()
{
return (java.util.Collections.unmodifiableMap(actions));
}
public static final Pattern ENV_NAME = Pattern.compile("^[A-Z_][A-Z0-9_]{0,99}$");
private String clientIdEnvName = "SLACK_CLIENT_ID";
private String clientSecretEnvName = "SLACK_CLIENT_SECRET";
private String signingSecretEnvName = "SLACK_SIGNING_SECRET";
private String oauthCallbackPath = "/qqq/hooks/slack/oauth/callback";
private Integer readinessIntervalMinutes = 15;
private List<String> phiPatterns = List.of("\\b\\d{3}-\\d{2}-\\d{4}\\b", "(?i)\\bMRN[:# ]?\\d{6,}\\b");
private String backendName;
private String schedulerName;
private String bridgeProviderName = "slack";
private transient Function<String, String> secretResolver = System::getenv;
private Set<String> allowedEnvNames = new LinkedHashSet<>(List.of("SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", "SLACK_TOKEN_KEY", "SLACK_TOKEN_KEY_PREVIOUS"));
private String tokenKeyEnvName = "SLACK_TOKEN_KEY";
private String previousTokenKeyEnvName = "SLACK_TOKEN_KEY_PREVIOUS";
private List<QFieldMetaData> securityFields = List.of();
private List<RecordSecurityLock> recordSecurityLocks = List.of();
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
private String appName = "slack";
private String appLabel = "Slack";
private Integer bodyRetentionDays = 30;
private Integer messageRetentionDays = 0;
private Integer maxAttempts = 6;
private List<Integer> retryMinutes = List.of(1, 5, 15, 60, 240, 720);
private Integer leaseSeconds = 120;
private Integer batchSize = 50;
private Integer dispatchIntervalSeconds = 10;
private Integer membershipCacheMinutes = 10;
private Long maxAttachmentBytes = 50L * 1024 * 1024;
private String slackApiBaseUrl = "https://slack.com/api";
private String publicBaseUrl;
private Integer connectTimeoutSeconds = 5;
private Integer requestTimeoutSeconds = 15;
/*******************************************************************************
** Check required settings.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, List<String> errors)
{
assertCondition(positive(readinessIntervalMinutes), "Slack qbit: readiness interval must be positive", errors);
assertCondition(oauthCallbackPath != null && oauthCallbackPath.matches("/qqq/hooks/slack/[a-zA-Z0-9/_-]+"), "Slack qbit: invalid OAuth callback path", errors);
assertCondition(java.util.Arrays.asList(clientIdEnvName, clientSecretEnvName, signingSecretEnvName).stream().allMatch(n -> n != null && ENV_NAME.matcher(n).matches()), "Slack qbit: invalid OAuth environment name", errors);
assertCondition(backendName != null && !backendName.isBlank(), "Slack qbit: backendName is required", errors);
assertCondition(schedulerName != null && !schedulerName.isBlank(), "Slack qbit: schedulerName is required", errors);
assertCondition(bridgeProviderName != null && bridgeProviderName.matches("[a-zA-Z][a-zA-Z0-9_]*"), "Slack qbit: bridgeProviderName must be a simple name", errors);
assertCondition(secretResolver != null, "Slack qbit: secretResolver is required", errors);
assertCondition(allowedEnvNames != null && allowedEnvNames.stream().allMatch(n -> n != null && ENV_NAME.matcher(n).matches()), "Slack qbit: allowedEnvNames must be valid environment variable names", errors);
assertCondition(tokenKeyEnvName != null && ENV_NAME.matcher(tokenKeyEnvName).matches(), "Slack qbit: tokenKeyEnvName must be a valid environment variable name", errors);
assertCondition(previousTokenKeyEnvName == null || ENV_NAME.matcher(previousTokenKeyEnvName).matches(), "Slack qbit: previousTokenKeyEnvName must be a valid environment variable name", errors);
assertCondition(appName != null && !appName.isBlank() && appLabel != null && !appLabel.isBlank(), "Slack qbit: appName and appLabel are required", errors);
assertCondition(securityFields != null && recordSecurityLocks != null, "Slack qbit: securityFields and recordSecurityLocks must not be null", errors);
assertCondition(positive(maxAttempts) && positive(batchSize) && positive(dispatchIntervalSeconds) && positive(membershipCacheMinutes), "Slack qbit: attempts, batch size, dispatch interval and membership cache must be positive", errors);
assertCondition(positive(connectTimeoutSeconds) && positive(requestTimeoutSeconds), "Slack qbit: HTTP timeouts must be positive", errors);
assertCondition(leaseSeconds != null && leaseSeconds >= 60, "Slack qbit: leaseSeconds must be at least 60", errors);
assertCondition(retryMinutes != null && !retryMinutes.isEmpty() && retryMinutes.stream().allMatch(n -> n != null && n >= 1), "Slack qbit: retryMinutes must contain positive delays", errors);
assertCondition(bodyRetentionDays != null && bodyRetentionDays >= 0 && messageRetentionDays != null && messageRetentionDays >= 0, "Slack qbit: retention days must not be negative", errors);
assertCondition(maxAttachmentBytes != null && maxAttachmentBytes > 0, "Slack qbit: maxAttachmentBytes must be positive", errors);
assertCondition(slackApiBaseUrl != null && secureOrLoopback(slackApiBaseUrl), "Slack qbit: slackApiBaseUrl must be https (or loopback http)", errors);
assertCondition(publicBaseUrl == null || secureOrLoopback(publicBaseUrl), "Slack qbit: publicBaseUrl must be https (or loopback http)", errors);
}
/*******************************************************************************
** True for a non-null positive number.
*******************************************************************************/
private static boolean positive(Integer value)
{
return (value != null && value >= 1);
}
/*******************************************************************************
** True for an https URL, or an http URL to a loopback host (tests).
*******************************************************************************/
private static boolean secureOrLoopback(String url)
{
return (url.startsWith("https://") || url.startsWith("http://localhost") || url.startsWith("http://127.0.0.1"));
}
/*******************************************************************************
** Retry delay in minutes after a one-based attempt number, repeating the last.
*******************************************************************************/
public int retryMinutesAfterAttempt(int attemptNumber)
{
return (retryMinutes.get(Math.max(0, Math.min(attemptNumber - 1, retryMinutes.size() - 1))));
}
/*******************************************************************************
** Tables default to the configured backend.
*******************************************************************************/
@Override
public String getDefaultBackendNameForTables()
{
return (backendName);
}
/*******************************************************************************
** Getter for backendName
*******************************************************************************/
public String getBackendName()
{
return (this.backendName);
}
/*******************************************************************************
** Fluent setter for backendName
*******************************************************************************/
public SlackQBitConfig withBackendName(String backendName)
{
this.backendName = backendName;
return (this);
}
/*******************************************************************************
** Getter for schedulerName
*******************************************************************************/
public String getSchedulerName()
{
return (this.schedulerName);
}
/*******************************************************************************
** Fluent setter for schedulerName
*******************************************************************************/
public SlackQBitConfig withSchedulerName(String schedulerName)
{
this.schedulerName = schedulerName;
return (this);
}
/*******************************************************************************
** Getter for bridgeProviderName
*******************************************************************************/
public String getBridgeProviderName()
{
return (this.bridgeProviderName);
}
/*******************************************************************************
** Fluent setter for bridgeProviderName
*******************************************************************************/
public SlackQBitConfig withBridgeProviderName(String bridgeProviderName)
{
this.bridgeProviderName = bridgeProviderName;
return (this);
}
/*******************************************************************************
** Getter for secretResolver
*******************************************************************************/
public Function<String, String> getSecretResolver()
{
return (this.secretResolver);
}
/*******************************************************************************
** Fluent setter for secretResolver
*******************************************************************************/
public SlackQBitConfig withSecretResolver(Function<String, String> secretResolver)
{
this.secretResolver = secretResolver;
return (this);
}
/*******************************************************************************
** Getter for allowedEnvNames
*******************************************************************************/
public Set<String> getAllowedEnvNames()
{
return (this.allowedEnvNames);
}
/*******************************************************************************
** Fluent setter for allowedEnvNames
*******************************************************************************/
public SlackQBitConfig withAllowedEnvNames(Set<String> allowedEnvNames)
{
this.allowedEnvNames = allowedEnvNames;
return (this);
}
/*******************************************************************************
** Getter for tokenKeyEnvName
*******************************************************************************/
public String getTokenKeyEnvName()
{
return (this.tokenKeyEnvName);
}
/*******************************************************************************
** Fluent setter for tokenKeyEnvName
*******************************************************************************/
public SlackQBitConfig withTokenKeyEnvName(String tokenKeyEnvName)
{
this.tokenKeyEnvName = tokenKeyEnvName;
return (this);
}
/*******************************************************************************
** Getter for previousTokenKeyEnvName
*******************************************************************************/
public String getPreviousTokenKeyEnvName()
{
return (this.previousTokenKeyEnvName);
}
/*******************************************************************************
** Fluent setter for previousTokenKeyEnvName
*******************************************************************************/
public SlackQBitConfig withPreviousTokenKeyEnvName(String previousTokenKeyEnvName)
{
this.previousTokenKeyEnvName = previousTokenKeyEnvName;
return (this);
}
/*******************************************************************************
** Getter for securityFields
*******************************************************************************/
public List<QFieldMetaData> getSecurityFields()
{
return (this.securityFields);
}
/*******************************************************************************
** Fluent setter for securityFields
*******************************************************************************/
public SlackQBitConfig withSecurityFields(List<QFieldMetaData> securityFields)
{
this.securityFields = securityFields;
return (this);
}
/*******************************************************************************
** Getter for recordSecurityLocks
*******************************************************************************/
public List<RecordSecurityLock> getRecordSecurityLocks()
{
return (this.recordSecurityLocks);
}
/*******************************************************************************
** Fluent setter for recordSecurityLocks
*******************************************************************************/
public SlackQBitConfig withRecordSecurityLocks(List<RecordSecurityLock> recordSecurityLocks)
{
this.recordSecurityLocks = recordSecurityLocks;
return (this);
}
/*******************************************************************************
** Getter for tableMetaDataCustomizer
*******************************************************************************/
public MetaDataCustomizerInterface<QTableMetaData> getTableMetaDataCustomizer()
{
return (this.tableMetaDataCustomizer);
}
/*******************************************************************************
** Fluent setter for tableMetaDataCustomizer
*******************************************************************************/
public SlackQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer)
{
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
return (this);
}
/*******************************************************************************
** Getter for appName
*******************************************************************************/
public String getAppName()
{
return (this.appName);
}
/*******************************************************************************
** Fluent setter for appName
*******************************************************************************/
public SlackQBitConfig withAppName(String appName)
{
this.appName = appName;
return (this);
}
/*******************************************************************************
** Getter for appLabel
*******************************************************************************/
public String getAppLabel()
{
return (this.appLabel);
}
/*******************************************************************************
** Fluent setter for appLabel
*******************************************************************************/
public SlackQBitConfig withAppLabel(String appLabel)
{
this.appLabel = appLabel;
return (this);
}
/*******************************************************************************
** Getter for bodyRetentionDays
*******************************************************************************/
public Integer getBodyRetentionDays()
{
return (this.bodyRetentionDays);
}
/*******************************************************************************
** Fluent setter for bodyRetentionDays
*******************************************************************************/
public SlackQBitConfig withBodyRetentionDays(Integer bodyRetentionDays)
{
this.bodyRetentionDays = bodyRetentionDays;
return (this);
}
/*******************************************************************************
** Getter for messageRetentionDays
*******************************************************************************/
public Integer getMessageRetentionDays()
{
return (this.messageRetentionDays);
}
/*******************************************************************************
** Fluent setter for messageRetentionDays
*******************************************************************************/
public SlackQBitConfig withMessageRetentionDays(Integer messageRetentionDays)
{
this.messageRetentionDays = messageRetentionDays;
return (this);
}
/*******************************************************************************
** Getter for maxAttempts
*******************************************************************************/
public Integer getMaxAttempts()
{
return (this.maxAttempts);
}
/*******************************************************************************
** Fluent setter for maxAttempts
*******************************************************************************/
public SlackQBitConfig withMaxAttempts(Integer maxAttempts)
{
this.maxAttempts = maxAttempts;
return (this);
}
/*******************************************************************************
** Getter for retryMinutes
*******************************************************************************/
public List<Integer> getRetryMinutes()
{
return (this.retryMinutes);
}
/*******************************************************************************
** Fluent setter for retryMinutes
*******************************************************************************/
public SlackQBitConfig withRetryMinutes(List<Integer> retryMinutes)
{
this.retryMinutes = retryMinutes;
return (this);
}
/*******************************************************************************
** Getter for leaseSeconds
*******************************************************************************/
public Integer getLeaseSeconds()
{
return (this.leaseSeconds);
}
/*******************************************************************************
** Fluent setter for leaseSeconds
*******************************************************************************/
public SlackQBitConfig withLeaseSeconds(Integer leaseSeconds)
{
this.leaseSeconds = leaseSeconds;
return (this);
}
/*******************************************************************************
** Getter for batchSize
*******************************************************************************/
public Integer getBatchSize()
{
return (this.batchSize);
}
/*******************************************************************************
** Fluent setter for batchSize
*******************************************************************************/
public SlackQBitConfig withBatchSize(Integer batchSize)
{
this.batchSize = batchSize;
return (this);
}
/*******************************************************************************
** Getter for dispatchIntervalSeconds
*******************************************************************************/
public Integer getDispatchIntervalSeconds()
{
return (this.dispatchIntervalSeconds);
}
/*******************************************************************************
** Fluent setter for dispatchIntervalSeconds
*******************************************************************************/
public SlackQBitConfig withDispatchIntervalSeconds(Integer dispatchIntervalSeconds)
{
this.dispatchIntervalSeconds = dispatchIntervalSeconds;
return (this);
}
/*******************************************************************************
** Getter for membershipCacheMinutes
*******************************************************************************/
public Integer getMembershipCacheMinutes()
{
return (this.membershipCacheMinutes);
}
/*******************************************************************************
** Fluent setter for membershipCacheMinutes
*******************************************************************************/
public SlackQBitConfig withMembershipCacheMinutes(Integer membershipCacheMinutes)
{
this.membershipCacheMinutes = membershipCacheMinutes;
return (this);
}
/*******************************************************************************
** Getter for maxAttachmentBytes
*******************************************************************************/
public Long getMaxAttachmentBytes()
{
return (this.maxAttachmentBytes);
}
/*******************************************************************************
** Fluent setter for maxAttachmentBytes
*******************************************************************************/
public SlackQBitConfig withMaxAttachmentBytes(Long maxAttachmentBytes)
{
this.maxAttachmentBytes = maxAttachmentBytes;
return (this);
}
/*******************************************************************************
** Getter for slackApiBaseUrl
*******************************************************************************/
public String getSlackApiBaseUrl()
{
return (this.slackApiBaseUrl);
}
/*******************************************************************************
** Fluent setter for slackApiBaseUrl
*******************************************************************************/
public SlackQBitConfig withSlackApiBaseUrl(String slackApiBaseUrl)
{
this.slackApiBaseUrl = slackApiBaseUrl;
return (this);
}
/*******************************************************************************
** Getter for publicBaseUrl
*******************************************************************************/
public String getPublicBaseUrl()
{
return (this.publicBaseUrl);
}
/*******************************************************************************
** Fluent setter for publicBaseUrl
*******************************************************************************/
public SlackQBitConfig withPublicBaseUrl(String publicBaseUrl)
{
this.publicBaseUrl = publicBaseUrl;
return (this);
}
/*******************************************************************************
** Getter for connectTimeoutSeconds
*******************************************************************************/
public Integer getConnectTimeoutSeconds()
{
return (this.connectTimeoutSeconds);
}
/*******************************************************************************
** Fluent setter for connectTimeoutSeconds
*******************************************************************************/
public SlackQBitConfig withConnectTimeoutSeconds(Integer connectTimeoutSeconds)
{
this.connectTimeoutSeconds = connectTimeoutSeconds;
return (this);
}
/*******************************************************************************
** Getter for requestTimeoutSeconds
*******************************************************************************/
public Integer getRequestTimeoutSeconds()
{
return (this.requestTimeoutSeconds);
}
/*******************************************************************************
** Fluent setter for requestTimeoutSeconds
*******************************************************************************/
public SlackQBitConfig withRequestTimeoutSeconds(Integer requestTimeoutSeconds)
{
this.requestTimeoutSeconds = requestTimeoutSeconds;
return (this);
}
/*******************************************************************************
** Getter for clientIdEnvName.
*******************************************************************************/
public String getClientIdEnvName()
{
return (clientIdEnvName);
}
/*******************************************************************************
** Fluent setter for clientIdEnvName.
*******************************************************************************/
public SlackQBitConfig withClientIdEnvName(String value)
{
clientIdEnvName = value;
return (this);
}
/*******************************************************************************
** Getter for clientSecretEnvName.
*******************************************************************************/
public String getClientSecretEnvName()
{
return (clientSecretEnvName);
}
/*******************************************************************************
** Fluent setter for clientSecretEnvName.
*******************************************************************************/
public SlackQBitConfig withClientSecretEnvName(String value)
{
clientSecretEnvName = value;
return (this);
}
/*******************************************************************************
** Getter for signingSecretEnvName.
*******************************************************************************/
public String getSigningSecretEnvName()
{
return (signingSecretEnvName);
}
/*******************************************************************************
** Fluent setter for signingSecretEnvName.
*******************************************************************************/
public SlackQBitConfig withSigningSecretEnvName(String value)
{
signingSecretEnvName = value;
return (this);
}
/*******************************************************************************
** Getter for oauthCallbackPath.
*******************************************************************************/
public String getOauthCallbackPath()
{
return (oauthCallbackPath);
}
/*******************************************************************************
** Fluent setter for oauthCallbackPath.
*******************************************************************************/
public SlackQBitConfig withOauthCallbackPath(String value)
{
oauthCallbackPath = value;
return (this);
}
/*******************************************************************************
** Getter for readinessIntervalMinutes.
*******************************************************************************/
public Integer getReadinessIntervalMinutes()
{
return (readinessIntervalMinutes);
}
/*******************************************************************************
** Fluent setter for readinessIntervalMinutes.
*******************************************************************************/
public SlackQBitConfig withReadinessIntervalMinutes(Integer value)
{
readinessIntervalMinutes = value;
return (this);
}
/*******************************************************************************
** Getter for phiPatterns.
*******************************************************************************/
public List<String> getPhiPatterns()
{
return (phiPatterns);
}
/*******************************************************************************
** Fluent setter for phiPatterns.
*******************************************************************************/
public SlackQBitConfig withPhiPatterns(List<String> value)
{
phiPatterns = value;
return (this);
}
}
@@ -0,0 +1,244 @@
/*
* 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.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;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
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;
/*******************************************************************************
** Entry point: produce the Slack qbit into a QInstance.
*******************************************************************************/
public class SlackQBitProducer implements QBitMetaDataProducer<SlackQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-slack";
public static final String VERSION = loadVersion();
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.
*******************************************************************************/
public SlackQBitProducer withQBitConfig(SlackQBitConfig config)
{
this.qBitConfig = config;
return (this);
}
/*******************************************************************************
** Fluent setter for the namespace.
*******************************************************************************/
public SlackQBitProducer withNamespace(String namespace)
{
this.namespace = namespace;
return (this);
}
/*******************************************************************************
** The qbit identity.
*******************************************************************************/
@Override
public QBitMetaData getQBitMetaData()
{
return (new QBitMetaData()
.withGroupId(GROUP_ID)
.withArtifactId(ARTIFACT_ID)
.withVersion(VERSION)
.withNamespace(namespace)
.withConfig(qBitConfig));
}
/*******************************************************************************
** A producer found by package scanning has no config: skip it.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (qBitConfig != null);
}
/*******************************************************************************
** Validate the config, then produce the security key types, possible-value
** sources, tables, relations and child-list widgets (docs/DESIGN.md 14.1).
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
{
List<String> errors = new ArrayList<>();
qBitConfig.validate(qInstance, errors);
if(!errors.isEmpty())
{
throw (new QException("Slack qbit configuration is invalid: " + String.join("; ", errors)));
}
qInstance.addQBit(getQBitMetaData());
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
SlackSecurity.buildSecurityKeyTypes().forEach(output::add);
SlackTables.possibleValueSources().forEach(output::add);
for(com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table : SlackTables.tables(qBitConfig))
{
if(qBitConfig.getTableMetaDataCustomizer() != null)
{
table = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
}
if(SlackTables.TEMPLATE.equals(table.getName()))
{
table.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.slack.template.SlackTemplateCustomizer.class));
table.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.slack.template.SlackTemplateCustomizer.class));
}
table.setSourceQBitName(getQBitMetaData().getName());
output.add(table);
}
SlackTables.joins().forEach(output::add);
SlackTables.childWidgets().forEach(output::add);
SlackProcesses.processes(qBitConfig).forEach(process ->
{
process.setSourceQBitName(getQBitMetaData().getName());
output.add(process);
});
SlackAlerts.types().forEach(output::add);
output.add(new SlackOutboxMessagingProviderMetaData().withName(qBitConfig.getBridgeProviderName()));
output.addSelfToInstance(qInstance);
com.kingsrook.qbits.slack.delivery.SlackDeliveryDestination.register();
qInstance.addApp(com.kingsrook.qbits.slack.app.SlackApp.app(qInstance.getTables()));
com.kingsrook.qbits.slack.app.SlackApp.widgets().forEach(qInstance::addWidget);
if(isPreferencesAvailable())
{
com.kingsrook.qbits.slack.preferences.SlackPreferencesProvider.register(qInstance);
}
com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance);
com.kingsrook.qbits.slack.interactivity.SlackActions.registerBuiltIns(qInstance, getQBitConfig());
try
{
Class.forName("com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckMetaData");
com.kingsrook.qbits.slack.readiness.SlackHealth.register(qInstance);
}
catch(ClassNotFoundException e)
{
// 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.
*******************************************************************************/
@Override
public SlackQBitConfig getQBitConfig()
{
return (qBitConfig);
}
/*******************************************************************************
** The namespace.
*******************************************************************************/
@Override
public String getNamespace()
{
return (namespace);
}
}
@@ -0,0 +1,155 @@
/*
* 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.api;
/*******************************************************************************
** Server-owned button definition. Applications register approve and reject with
** their own process and permission names; neither is a built-in approval policy.
*******************************************************************************/
public class SlackActionMetaData
{
private String name;
private String label;
private String style;
private String confirmTitle;
private String confirmText;
private String processName;
private String permissionName;
/*******************************************************************************
** Get name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Configure name.
*******************************************************************************/
public SlackActionMetaData withName(String value)
{
name = value;
return (this);
}
/*******************************************************************************
** Get label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Configure label.
*******************************************************************************/
public SlackActionMetaData withLabel(String value)
{
label = value;
return (this);
}
/*******************************************************************************
** Get style.
*******************************************************************************/
public String getStyle()
{
return (style);
}
/*******************************************************************************
** Configure style.
*******************************************************************************/
public SlackActionMetaData withStyle(String value)
{
style = value;
return (this);
}
/*******************************************************************************
** Get confirmTitle.
*******************************************************************************/
public String getConfirmTitle()
{
return (confirmTitle);
}
/*******************************************************************************
** Configure confirmTitle.
*******************************************************************************/
public SlackActionMetaData withConfirmTitle(String value)
{
confirmTitle = value;
return (this);
}
/*******************************************************************************
** Get confirmText.
*******************************************************************************/
public String getConfirmText()
{
return (confirmText);
}
/*******************************************************************************
** Configure confirmText.
*******************************************************************************/
public SlackActionMetaData withConfirmText(String value)
{
confirmText = value;
return (this);
}
/*******************************************************************************
** Get processName.
*******************************************************************************/
public String getProcessName()
{
return (processName);
}
/*******************************************************************************
** Configure processName.
*******************************************************************************/
public SlackActionMetaData withProcessName(String value)
{
processName = value;
return (this);
}
/*******************************************************************************
** Get permissionName.
*******************************************************************************/
public String getPermissionName()
{
return (permissionName);
}
/*******************************************************************************
** Configure permissionName.
*******************************************************************************/
public SlackActionMetaData withPermissionName(String value)
{
permissionName = value;
return (this);
}
}
@@ -0,0 +1,324 @@
/*
* 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.api;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
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.SlackSendResult;
import com.kingsrook.qbits.slack.outbox.SlackService;
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
import com.kingsrook.qbits.slack.security.SlackAuthorizer;
import com.kingsrook.qbits.slack.security.SlackOwnerSession;
import com.kingsrook.qbits.slack.security.SlackTokens;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qbits.slack.template.SlackTemplates;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.session.QSystemUserSession;
/*******************************************************************************
** The only public surface for other qbits. Changes within a 0.x minor release
** line are additive only; implementation packages are not API and may change.
** Database operations use the current QContext and its tenant/owner locks.
** Projections never include credentials, bodies or variable snapshots. Sends
** retain permission, owner, tenant, template and PHI checks and always use the
** outbox. QUEUED acknowledges storage, not delivery.
*******************************************************************************/
public final class SlackApi
{
/*******************************************************************************
** Apply Slack owner and body-permission keys to a host-created session.
** Idempotently replaces earlier Slack keys. Null is a no-op; a missing user
** gets no owner key. Hosts must authorize allAccess before passing true.
*******************************************************************************/
public static void applySessionKeys(com.kingsrook.qqq.backend.core.model.session.QSession session, boolean allAccess)
{
com.kingsrook.qbits.slack.security.SlackSecurity.applyToSession(session,
session == null || session.getUser() == null ? null : session.getUser().getIdReference(), allAccess);
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackApi()
{
}
/*******************************************************************************
** True when produced into the supplied instance. Null is not installed.
*******************************************************************************/
public static boolean isInstalled(QInstance instance)
{
return (SlackRuntime.config(instance) != null);
}
/*******************************************************************************
** Queue through the same service as core messaging; null is refused.
*******************************************************************************/
public static SlackApiResult send(SlackApiRequest request) throws QException
{
return (result(SlackService.enqueue(request(request))));
}
/*******************************************************************************
** One committed durable handoff. Refuse caller transactions and conflicting
** keys; success means committed storage, not delivery. Database failures
** propagate as QException. The immutable request is never modified.
*******************************************************************************/
public static SlackApiEffectResult sendOnce(SlackApiRequest request, String effectKey) throws QException
{
if(request == null || effectKey == null || effectKey.isBlank() || effectKey.length() > 500
|| request.idempotencyKey() != null && !request.idempotencyKey().equals(effectKey))
{
return (new SlackApiEffectResult("REFUSED", null));
}
SlackSendResult result = SlackService.sendOnce(request(request), effectKey);
return (new SlackApiEffectResult(result.status().name(), result.messageId() == null ? null : getMessage(result.messageId()).orElse(null)));
}
/*******************************************************************************
** Queue a thread reply owned by the same user; waits for an unsent parent.
*******************************************************************************/
public static SlackApiResult reply(Integer parentMessageId, SlackApiRequest request) throws QException
{
if(parentMessageId == null || request == null)
{
return (result(SlackSendResult.refused("A parent and request are required")));
}
return (send(request.withThreadOfMessageId(parentMessageId)));
}
/*******************************************************************************
** Queue an update without changing the original posting identity.
*******************************************************************************/
public static SlackApiResult update(Integer messageId, SlackApiRequest request) throws QException
{
return (result(SlackService.update(messageId, request(request))));
}
/*******************************************************************************
** Visible message metadata. Null and unknown ids return empty.
*******************************************************************************/
public static Optional<SlackApiMessage> getMessage(Integer id) throws QException
{
return (id == null ? Optional.empty() : first(SlackStore.equal("id", id)));
}
/*******************************************************************************
** Visible message metadata by exact key. Unknown keys return empty.
*******************************************************************************/
public static Optional<SlackApiMessage> getMessageByIdempotencyKey(String key) throws QException
{
return (key == null ? Optional.empty() : first(SlackStore.equal("idempotencyKey", key)));
}
/*******************************************************************************
** Literal prefix matching, bounded to 1..1000 visible projections.
*******************************************************************************/
public static List<SlackApiMessage> findMessagesByIdempotencyKeyPrefix(String prefix, int limit) throws QException
{
if(prefix == null || prefix.isBlank() || limit < 1 || limit > 1000)
{
throw (new IllegalArgumentException("A prefix and limit from 1 to 1000 are required"));
}
return (SlackStore.queryAsCurrent(SlackTables.MESSAGE, new QQueryFilter()).stream()
.filter(r -> r.getValueString("idempotencyKey") != null && r.getValueString("idempotencyKey").startsWith(prefix))
.limit(limit).map(SlackApi::message).toList());
}
/*******************************************************************************
** Deployment app and outbox readiness. Requires this instance's QContext.
*******************************************************************************/
public static SlackApiReadiness readiness(QInstance instance) throws QException
{
return (SlackReadiness.deployment(instance));
}
/*******************************************************************************
** The user's connected workspaces and own token checks. A user cannot name
** another user; a system caller explicitly assumes that owner's session.
*******************************************************************************/
public static SlackApiReadiness readinessFor(String userId) throws QException
{
requireUser(userId);
return (SlackOwnerSession.call(userId, () ->
{
List<SlackApiReadinessCheck> checks = new ArrayList<>();
for(SlackApiConnection connection : connectionsFor(userId))
{
checks.addAll(SlackReadiness.workspace(connection.workspaceId()).checks());
checks.add(SlackReadiness.connection(connection.id()));
}
if(checks.isEmpty())
{
checks.add(new SlackApiReadinessCheck("connections", "ERROR", "Connect Slack first"));
}
return (SlackReadiness.result(checks));
}));
}
/*******************************************************************************
** Connections visible to their owner, with workspace labels and no tokens.
*******************************************************************************/
public static List<SlackApiConnection> connectionsFor(String userId) throws QException
{
requireUser(userId);
return (SlackOwnerSession.call(userId, () ->
{
List<SlackApiConnection> result = new ArrayList<>();
for(QRecord row : SlackStore.queryAsCurrent(SlackTables.CONNECTION, SlackStore.equal("userId", userId)))
{
QRecord workspace = SlackConnect.visible(SlackTables.WORKSPACE, row.getValueInteger("workspaceId"));
result.add(new SlackApiConnection(row.getValueInteger("id"), row.getValueInteger("workspaceId"), workspace.getValueString("teamName"),
workspace.getValueString("teamId"), row.getValueString("status")));
}
return (List.copyOf(result));
}));
}
/*******************************************************************************
** Picker options from the user's own token, reauthorized for membership.
** Pagination is bounded; no token from another connection is a fallback.
*******************************************************************************/
public static List<SlackApiTarget> listTargets(String userId, Integer workspaceId) throws QException
{
requireUser(userId);
return (SlackOwnerSession.call(userId, () ->
{
SlackApiConnection connection = connectionsFor(userId).stream().filter(c -> c.workspaceId().equals(workspaceId) && "ACTIVE".equals(c.status())).findFirst().orElse(null);
if(connection == null)
{
return (List.of());
}
List<SlackApiTarget> result = new ArrayList<>();
result.add(new SlackApiTarget("me", "Direct message to me"));
String token = SlackTokens.userToken(SlackRuntime.config(), connection.id());
String cursor = null;
for(int page = 0; page < 100; page++)
{
var response = SlackRuntime.client(SlackRuntime.config()).usersConversations(token, cursor);
if(!response.isOk())
{
throw (new QException("Slack targets unavailable"));
}
var channels = response.getJson().optJSONArray("channels");
if(channels != null)
{
for(int i = 0; i < channels.length(); i++)
{
var channel = channels.getJSONObject(i);
String address = "channel:" + workspaceId + "/" + channel.getString("id");
var allowed = new SlackAuthorizer().authorize(new SlackAuthorizer.Request().withOwnerUserId(userId).withWorkspaceId(workspaceId).withAddress(address));
if(allowed.isAuthorized())
{
result.add(new SlackApiTarget(address, channel.optString("name", "Channel")));
}
}
}
var metadata = response.getJson().optJSONObject("response_metadata");
cursor = metadata == null ? null : metadata.optString("next_cursor", null);
if(cursor == null || cursor.isBlank())
{
return (List.copyOf(result));
}
}
throw (new QException("Slack target pagination exceeded"));
}));
}
/*******************************************************************************
** Seed once without overwriting edits. System or tenant admin only.
*******************************************************************************/
public static boolean ensureTemplate(SlackApiTemplate template) throws QException
{
if(template == null)
{
throw (new IllegalArgumentException("A template is required"));
}
return (SlackTemplates.ensure(new QRecord().withValue("name", template.name()).withValue("label", template.label())
.withValue("text", template.text()).withValue("blocksTemplate", template.blocksTemplate())
.withValue("variablesSchema", template.variablesSchema()).withValue("category", template.category())));
}
/*******************************************************************************
** Never switch a signed-in caller to someone else's session.
*******************************************************************************/
private static void requireUser(String user)
{
var session = QContext.getQSession();
if(user == null || session == null || !(session instanceof QSystemUserSession)
&& (session.getUser() == null || !user.equals(session.getUser().getIdReference())))
{
throw (new IllegalArgumentException("Slack user is not available"));
}
}
/*******************************************************************************
** Translate the stable request into the implementation request.
*******************************************************************************/
private static SlackSendRequest request(SlackApiRequest request)
{
if(request == null)
{
return (null);
}
return (new SlackSendRequest().withActions(request.actions()).withOwnerUserId(request.ownerUserId()).withTo(request.to()).withWorkspaceId(request.workspaceId())
.withText(request.text()).withBlocksJson(request.blocksJson()).withTemplateName(request.templateName()).withVariables(request.variables())
.withIdempotencyKey(request.idempotencyKey()).withCategory(request.category()).withPostAs(request.postAs() == null ? null : SlackEnums.PostAs.valueOf(request.postAs()))
.withThreadOfMessageId(request.threadOfMessageId()).withLinkedRecord(request.linkedTableName(), request.linkedRecordId()).withSenderName(request.senderName())
.withContentClassification(request.contentClassification()).withTitle(request.title()).withLink(request.link()));
}
/*******************************************************************************
** Copy service result into the stable contract.
*******************************************************************************/
private static SlackApiResult result(SlackSendResult result)
{
return (new SlackApiResult(result.status().name(), result.messageId(), result.reasons()));
}
/*******************************************************************************
** Read a single metadata projection through current-session record locks.
*******************************************************************************/
private static Optional<SlackApiMessage> first(QQueryFilter filter) throws QException
{
var rows = SlackStore.queryAsCurrent(SlackTables.MESSAGE, filter.withLimit(1));
return (rows.isEmpty() ? Optional.empty() : Optional.of(message(rows.getFirst())));
}
/*******************************************************************************
** Explicit allow-list of public message fields.
*******************************************************************************/
private static SlackApiMessage message(QRecord row)
{
return (new SlackApiMessage(row.getValueInteger("id"), row.getValueString("status"), row.getValueString("ownerUserId"),
row.getValueInteger("workspaceId"), row.getValueString("idempotencyKey"), row.getValueString("slackTs"), row.getValueString("permalink"), row.getValueString("category")));
}
}
@@ -0,0 +1,28 @@
/*
* 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.api;
/*******************************************************************************
** Visible connection identity; no credential fields.
*******************************************************************************/
public record SlackApiConnection(Integer id, Integer workspaceId, String workspaceLabel, String teamId, String status)
{
}
@@ -0,0 +1,28 @@
/*
* 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.api;
/*******************************************************************************
** A durable handoff acknowledgement, not a delivery acknowledgement.
*******************************************************************************/
public record SlackApiEffectResult(String outcome, SlackApiMessage message)
{
}
@@ -0,0 +1,28 @@
/*
* 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.api;
/*******************************************************************************
** Metadata projection. Never contains bodies, variables or credentials.
*******************************************************************************/
public record SlackApiMessage(Integer id, String status, String ownerUserId, Integer workspaceId, String idempotencyKey, String slackTs, String permalink, String category)
{
}
@@ -0,0 +1,35 @@
/*
* 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.api;
/*******************************************************************************
** Immutable result with a defensive collection snapshot.
*******************************************************************************/
public record SlackApiReadiness(boolean ready, java.util.List<SlackApiReadinessCheck> checks)
{
/*******************************************************************************
** Retain an immutable snapshot.
*******************************************************************************/
public SlackApiReadiness
{
checks = java.util.List.copyOf(checks);
}
}
@@ -0,0 +1,28 @@
/*
* 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.api;
/*******************************************************************************
** Fixed diagnostic; never includes provider response or credential text.
*******************************************************************************/
public record SlackApiReadinessCheck(String name, String status, String summary)
{
}
@@ -0,0 +1,228 @@
/*
* 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.api;
/*******************************************************************************
** Immutable enqueue request. Fluent withX methods return a new request.
** QUEUED means durable outbox storage, never synchronous delivery.
*******************************************************************************/
public record SlackApiRequest(String ownerUserId, String to, Integer workspaceId, String text, String blocksJson, String templateName, java.util.Map<String, java.io.Serializable> variables, String idempotencyKey, String category, String postAs, Integer threadOfMessageId, String linkedTableName, String linkedRecordId, String senderName, String contentClassification, String title, String link, java.util.List<String> actions)
{
/*******************************************************************************
** Compatibility constructor for requests without buttons.
*******************************************************************************/
public SlackApiRequest(String ownerUserId, String to, Integer workspaceId, String text, String blocksJson, String templateName, java.util.Map<String, java.io.Serializable> variables, String idempotencyKey, String category, String postAs, Integer threadOfMessageId, String linkedTableName, String linkedRecordId, String senderName, String contentClassification, String title, String link)
{
this(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, java.util.List.of());
}
/*******************************************************************************
** Add a registered button to the immutable request.
*******************************************************************************/
public SlackApiRequest withAction(String name)
{
java.util.List<String> copy = new java.util.ArrayList<>(actions);
copy.add(name);
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, copy));
}
/*******************************************************************************
** Empty request for fluent construction.
*******************************************************************************/
public SlackApiRequest()
{
this(null, null, null, null, null, null, java.util.Map.of(), null, null, null, null, null, null, null, null, null, null, java.util.List.of());
}
/*******************************************************************************
** Snapshot the variable map at construction.
*******************************************************************************/
public SlackApiRequest
{
actions = actions == null ? java.util.List.of() : java.util.List.copyOf(actions);
java.util.Map<String, java.io.Serializable> copy = new java.util.LinkedHashMap<>();
if(variables != null)
{
variables.forEach((name, value) -> copy.put(name, freeze(value)));
}
variables = java.util.Collections.unmodifiableMap(copy);
}
/*******************************************************************************
** Copy with ownerUserId.
*******************************************************************************/
public SlackApiRequest withOwnerUserId(String value)
{
return (new SlackApiRequest(value, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with to.
*******************************************************************************/
public SlackApiRequest withTo(String value)
{
return (new SlackApiRequest(ownerUserId, value, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with workspaceId.
*******************************************************************************/
public SlackApiRequest withWorkspaceId(Integer value)
{
return (new SlackApiRequest(ownerUserId, to, value, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with text.
*******************************************************************************/
public SlackApiRequest withText(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, value, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with blocksJson.
*******************************************************************************/
public SlackApiRequest withBlocksJson(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, value, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with templateName.
*******************************************************************************/
public SlackApiRequest withTemplateName(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, value, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with variables.
*******************************************************************************/
public SlackApiRequest withVariables(java.util.Map<String, java.io.Serializable> value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, value, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with idempotencyKey.
*******************************************************************************/
public SlackApiRequest withIdempotencyKey(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, value, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with category.
*******************************************************************************/
public SlackApiRequest withCategory(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, value, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with postAs.
*******************************************************************************/
public SlackApiRequest withPostAs(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, value, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with threadOfMessageId.
*******************************************************************************/
public SlackApiRequest withThreadOfMessageId(Integer value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, value, linkedTableName, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with linkedTableName.
*******************************************************************************/
public SlackApiRequest withLinkedTableName(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, value, linkedRecordId, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with linkedRecordId.
*******************************************************************************/
public SlackApiRequest withLinkedRecordId(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, value, senderName, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with senderName.
*******************************************************************************/
public SlackApiRequest withSenderName(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, value, contentClassification, title, link, actions));
}
/*******************************************************************************
** Copy with contentClassification.
*******************************************************************************/
public SlackApiRequest withContentClassification(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, value, title, link, actions));
}
/*******************************************************************************
** Copy with title.
*******************************************************************************/
public SlackApiRequest withTitle(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, value, link, actions));
}
/*******************************************************************************
** Copy with link.
*******************************************************************************/
public SlackApiRequest withLink(String value)
{
return (new SlackApiRequest(ownerUserId, to, workspaceId, text, blocksJson, templateName, variables, idempotencyKey, category, postAs, threadOfMessageId, linkedTableName, linkedRecordId, senderName, contentClassification, title, value, actions));
}
/*******************************************************************************
** Recursively snapshot structured values. Immutable scalar values are retained.
*******************************************************************************/
private static java.io.Serializable freeze(java.io.Serializable value)
{
if(value instanceof java.util.Map<?, ?> map)
{
java.util.Map<String, java.io.Serializable> copy = new java.util.LinkedHashMap<>();
map.forEach((key, item) -> copy.put(String.valueOf(key), freeze((java.io.Serializable) item)));
return ((java.io.Serializable) java.util.Collections.unmodifiableMap(copy));
}
if(value instanceof java.util.List<?> list)
{
java.util.List<java.io.Serializable> copy = new java.util.ArrayList<>();
list.forEach(item -> copy.add(freeze((java.io.Serializable) item)));
return ((java.io.Serializable) java.util.Collections.unmodifiableList(copy));
}
if(value instanceof SlackApiVariable variable)
{
return (new SlackApiVariable(freeze(variable.value()), variable.classification(), variable.table(), variable.field()));
}
return (value);
}
}
@@ -0,0 +1,35 @@
/*
* 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.api;
/*******************************************************************************
** Immutable result with a defensive collection snapshot.
*******************************************************************************/
public record SlackApiResult(String status, Integer messageId, java.util.List<String> reasons)
{
/*******************************************************************************
** Retain an immutable snapshot.
*******************************************************************************/
public SlackApiResult
{
reasons = java.util.List.copyOf(reasons);
}
}
@@ -0,0 +1,28 @@
/*
* 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.api;
/*******************************************************************************
** One authorized picker option.
*******************************************************************************/
public record SlackApiTarget(String address, String label)
{
}
@@ -0,0 +1,28 @@
/*
* 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.api;
/*******************************************************************************
** Immutable template definition. ensureTemplate never overwrites administrator edits.
*******************************************************************************/
public record SlackApiTemplate(String name, String label, String text, String blocksTemplate, String variablesSchema, String category)
{
}
@@ -0,0 +1,39 @@
/*
* 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.api;
import java.io.Serializable;
/*******************************************************************************
** Provenance for a runtime variable. Bound-field classification comes from
** core metadata; a declaration can increase but never decrease sensitivity.
*******************************************************************************/
public record SlackApiVariable(Serializable value, String classification, String table, String field) implements Serializable
{
/*******************************************************************************
** Values must not appear in diagnostic formatting.
*******************************************************************************/
@Override
public String toString()
{
return ("[classified variable]");
}
}
@@ -0,0 +1,105 @@
/*
* 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.app;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Full-width typed home widgets; processes remain record actions.
*******************************************************************************/
public final class SlackApp
{
public static final String HOME_WIDGET = "slackHome";
public static final String DELIVERIES = "slackHomeDeliveries";
public static final String RECENT = "slackHomeRecent";
public static final String READINESS = "slackHomeReadiness";
public static final String OUTBOX = "slackHomeOutbox";
public static final String FAILURES = "slackHomeFailures";
public static final List<String> ADMIN_WIDGETS = List.of(READINESS, OUTBOX, FAILURES);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackApp()
{
}
/*******************************************************************************
** Navigation in owner-requested order.
*******************************************************************************/
public static QAppMetaData app(Map<String, QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData().withName("slack").withLabel("Slack").withIcon(new QIcon("chat"))
.withPermissionRules(SlackTables.permissionRules()).withWidgets(widgets().stream().map(QWidgetMetaData::getName).toList());
SlackTables.MAIN.forEach(name -> app.withChild(tables.get(name)));
return (app);
}
/*******************************************************************************
** Each section occupies all twelve grid columns, including on small screens.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(widget(HOME_WIDGET, "My connections", WidgetType.TABLE),
widget(DELIVERIES, "My deliveries (24 h)", WidgetType.MULTI_STATISTICS),
widget(RECENT, "My recent deliveries", WidgetType.TABLE),
widget(READINESS, "Workspace readiness", WidgetType.TABLE),
widget(OUTBOX, "Outbox", WidgetType.MULTI_STATISTICS),
widget(FAILURES, "Recent failures", WidgetType.TABLE)));
}
/*******************************************************************************
** Labels belong to widget metadata, never duplicated inside their payloads.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType())
.withGridColumns(12).withIsCard(true).withShowReloadButton(true)
.withCodeReference(new QCodeReference(HomeRenderer.class)).withPermissionRules(SlackTables.permissionRules()));
}
/*******************************************************************************
** Render with the request's current context.
*******************************************************************************/
public static class HomeRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Viewer-scoped dashboard.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(SlackHome.render(input.getWidgetMetaData().getName())));
}
}
}
@@ -0,0 +1,199 @@
/*
* 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.app;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
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.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData.Statistic;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Exact counts and bounded recent lists, always under the viewer's record locks.
*******************************************************************************/
public final class SlackHome
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackHome()
{
}
/*******************************************************************************
** Typed sections fetch only their data, with permissions checked on direct calls.
*******************************************************************************/
public static QWidgetData render(String name) throws QException
{
String permission = SlackApp.ADMIN_WIDGETS.contains(name) ? SlackPermissions.ADMIN : SlackPermissions.VIEW;
if(!SlackPermissionChecker.has(permission))
{
throw (new QPermissionDeniedException("This needs the " + permission + " permission."));
}
String owner = QContext.getQSession().getUser().getIdReference();
return (switch(name)
{
case SlackApp.HOME_WIDGET -> connections(owner);
case SlackApp.DELIVERIES -> deliveries(owner);
case SlackApp.RECENT -> SlackHomeView.messages(messages(SlackStore.equal("ownerUserId", owner)), SlackStore.equal("ownerUserId", owner));
case SlackApp.READINESS -> SlackHomeView.readiness(SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new QQueryFilter()));
case SlackApp.OUTBOX -> outbox();
case SlackApp.FAILURES -> SlackHomeView.messages(messages(failures()), failures());
default -> throw new QException("Unknown Slack home widget.");
});
}
/*******************************************************************************
** Resolve workspace labels without granting access to the workspace admin UI.
*******************************************************************************/
private static QWidgetData connections(String owner) throws QException
{
List<QRecord> connections = rows(SlackTables.CONNECTION, SlackStore.equal("userId", owner), Set.of("id", "workspaceId", "status", "connectedAt"));
QQueryFilter filter = new QQueryFilter().withCriteria("id", QCriteriaOperator.IN,
connections.stream().map(row -> row.getValueInteger("workspaceId")).toList());
List<QRecord> workspaces = connections.isEmpty() ? List.of() : SlackStore.queryAsCurrent(SlackTables.WORKSPACE, filter);
return (SlackHomeView.connections(connections, workspaces));
}
/*******************************************************************************
** Omit empty personal statuses, but always show Sent.
*******************************************************************************/
private static MultiStatisticsData deliveries(String owner) throws QException
{
List<StatisticsGroupData> groups = new ArrayList<>();
for(var status : SlackEnums.MessageStatus.values())
{
QQueryFilter filter = countFilter(status.name(), owner, "createDate");
int total = count(filter);
if(total > 0 || status == SlackEnums.MessageStatus.SENT)
{
groups.add(statistic(SlackHomeView.messageStatus(status.name()), total, filter));
}
}
return (new MultiStatisticsData(null, groups));
}
/*******************************************************************************
** Backlog counts are current; terminal counts cover their event time in 24 h.
*******************************************************************************/
private static MultiStatisticsData outbox() throws QException
{
List<StatisticsGroupData> groups = new ArrayList<>();
String[] statuses = {"QUEUED", "RETRY_WAIT", "SENT", "DEAD_LETTER", "FAILED_AUTH"};
String[] labels = {"Queued", "Retrying", "Sent in 24 h", "Dead letters in 24 h", "Not authorized in 24 h"};
String[] dates = {null, null, "sentDate", "deadLetterDate", "modifyDate"};
for(int i = 0; i < statuses.length; i++)
{
QQueryFilter filter = countFilter(statuses[i], null, dates[i]);
groups.add(statistic(labels[i], count(filter), filter));
}
return (new MultiStatisticsData(null, groups));
}
/*******************************************************************************
** Each tile links to exactly the same filter as its count.
*******************************************************************************/
private static StatisticsGroupData statistic(String label, int total, QQueryFilter filter)
{
return (new StatisticsGroupData().withHeader(label)
.withStatisticList(List.of(new Statistic("Messages", total, SlackHomeView.listUrl(filter)))));
}
/*******************************************************************************
** Count in core, with an inclusive lower and upper bound for the last day.
*******************************************************************************/
public static int count(String status, String owner, String dateField) throws QException
{
return (count(countFilter(status, owner, dateField)));
}
/*******************************************************************************
** One shared filter for counts and their drill-down links.
*******************************************************************************/
private static QQueryFilter countFilter(String status, String owner, String dateField)
{
QQueryFilter filter = SlackStore.equal("status", status);
if(owner != null)
{
filter.withCriteria("ownerUserId", QCriteriaOperator.EQUALS, owner);
}
if(dateField != null)
{
Instant now = SlackRuntime.clock().instant();
filter.withCriteria(dateField, QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400));
filter.withCriteria(dateField, QCriteriaOperator.LESS_THAN_OR_EQUALS, now);
}
return (filter);
}
/*******************************************************************************
** Count under the current viewer's owner and tenant locks.
*******************************************************************************/
private static int count(QQueryFilter filter) throws QException
{
return (new CountAction().execute(new CountInput(SlackTables.MESSAGE).withFilter(filter)).getCount());
}
/*******************************************************************************
** Recoverable and terminal failures share the same list and drill-down filter.
*******************************************************************************/
private static QQueryFilter failures()
{
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("DEAD_LETTER", "FAILED_AUTH", "RETRY_WAIT")));
}
/*******************************************************************************
** Last ten messages, stable ordering even for equal timestamps, without bodies.
*******************************************************************************/
private static List<QRecord> messages(QQueryFilter filter) throws QException
{
filter.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(10);
return (rows(SlackTables.MESSAGE, filter, Set.of("id", "status", "channelLabel", "createDate")));
}
/*******************************************************************************
** Never switch to a system context for dashboard queries.
*******************************************************************************/
private static List<QRecord> rows(String table, QQueryFilter filter, Set<String> fields) throws QException
{
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withFieldNamesToInclude(fields)).getRecords());
}
}
@@ -0,0 +1,188 @@
/*
* 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.app;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.readiness.SlackReadiness;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Typed tables inherit responsive layout and themes from the QQQ dashboard.
*******************************************************************************/
public final class SlackHomeView
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackHomeView()
{
}
/*******************************************************************************
** Workspace names link to the viewer's connection, never an admin-only record.
*******************************************************************************/
public static TableData connections(List<QRecord> connections, List<QRecord> workspaces)
{
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord row : connections)
{
String workspace = workspaces.stream().filter(w -> w.getValueInteger("id").equals(row.getValueInteger("workspaceId")))
.map(w -> w.getValueString("teamName")).findFirst().orElse("Workspace " + row.getValueInteger("workspaceId"));
rows.add(Map.of("workspace", link(SlackTables.CONNECTION, row, workspace),
"status", label(row.getValueString("status")), "connected", date(row.getValueInstant("connectedAt"))));
}
return (table(List.of(column("Workspace", "workspace", "html"), column("Status", "status", "text"), column("Connected", "connected", "text")),
rows, "No connections yet. Use Connect Slack on Connections."));
}
/*******************************************************************************
** Linked rows contain delivery metadata, never message bodies.
*******************************************************************************/
public static TableData messages(List<QRecord> messages, QQueryFilter filter)
{
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord row : messages)
{
rows.add(Map.of("message", link(SlackTables.MESSAGE, row, "Message " + row.getValueInteger("id")),
"status", messageStatus(row.getValueString("status")), "where", row.getValueString("channelLabel") == null ? "Not recorded" : row.getValueString("channelLabel"),
"when", date(row.getValueInstant("createDate"))));
}
return (table(List.of(column("Message", "message", "html"), column("Status", "status", "text"), column("Where", "where", "text"), column("When", "when", "text")),
rows, "No deliveries.").withLinkText("View all").withLinkURL(listUrl(filter)));
}
/*******************************************************************************
** One row per workspace check; successful checks have no remediation hint.
*******************************************************************************/
public static TableData readiness(List<QRecord> workspaces)
{
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord workspace : workspaces)
{
for(var check : SlackReadiness.workspaceSnapshot(workspace.getValueInteger("id")).checks())
{
boolean channels = "registeredChannels".equals(check.name());
String status = switch(check.status())
{
case "READY" -> "READY";
case "WARN" -> "DEGRADED";
default -> "NOT READY";
};
String fix = "READY".equals(status) ? "" : "UNKNOWN".equals(check.status()) ? "Run Check readiness to refresh."
: channels ? "Check registered channels, unarchive them and invite the bot. Run Check readiness to refresh."
: "Install or reconnect this workspace with the required scopes. Run Check readiness to refresh.";
rows.add(Map.of("workspace", link(SlackTables.WORKSPACE, workspace, workspace.getValueString("teamName")),
"check", channels ? "Registered channels" : "Bot token and scopes", "status", status, "fix", fix));
}
}
return (table(List.of(column("Workspace", "workspace", "html"), column("Check", "check", "text"), column("Status", "status", "text"), column("Fix", "fix", "text")),
rows, "No workspaces installed. An administrator runs Install for my organization on Connections."));
}
/*******************************************************************************
** Human delivery labels clarify exceptional states instead of exposing enums.
*******************************************************************************/
public static String messageStatus(String status)
{
if(status == null)
{
return ("Not recorded");
}
return (switch(SlackEnums.MessageStatus.valueOf(status))
{
case WAITING_PARENT -> "Waiting for parent";
case FAILED_AUTH -> "Failed: not authorized";
case RETRY_WAIT -> "Retrying";
default -> label(status);
});
}
/*******************************************************************************
** Plain enum display label for connection and remaining delivery states.
*******************************************************************************/
private static String label(String value)
{
return (value == null ? "Not recorded" : value.charAt(0) + value.substring(1).toLowerCase(Locale.ROOT).replace('_', ' '));
}
/*******************************************************************************
** Use the viewer's timezone, falling back to the application's configured zone.
*******************************************************************************/
private static String date(Instant value)
{
return (value == null ? "Not recorded" : DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm a z", Locale.ENGLISH)
.withZone(ValueUtils.getSessionOrInstanceZoneId()).format(value));
}
/*******************************************************************************
** A Messages list filtered to the count or recent list's actual scope.
*******************************************************************************/
public static String listUrl(QQueryFilter filter)
{
return ("/app/" + SlackTables.MESSAGE + "?filter=" + URLEncoder.encode(JsonUtils.toJson(filter), StandardCharsets.UTF_8));
}
/*******************************************************************************
** Only record links use HTML; ordinary text is escaped by the table renderer.
*******************************************************************************/
private static TableData.Column column(String header, String accessor, String type)
{
return (new TableData.Column(type, header, accessor, null, "left").withVerticalAlign("top"));
}
/*******************************************************************************
** Full-width table with an honest empty state and no unnecessary page selector.
*******************************************************************************/
private static TableData table(List<TableData.Column> columns, List<Map<String, Object>> rows, String empty)
{
return (new TableData(null, columns, rows).withRowsPerPage(Math.max(10, rows.size()))
.withHidePaginationDropdown(true).withNoRowsFoundHTML(empty));
}
/*******************************************************************************
** Only integer record ids and internal table names enter URLs.
*******************************************************************************/
private static String link(String table, QRecord row, String label)
{
return ("<a href=\"/app/" + table + "/" + row.getValueInteger("id") + "\">" + esc(label) + "</a>");
}
/*******************************************************************************
** Escape record-sourced labels in HTML cells.
*******************************************************************************/
private static String esc(String value)
{
return (value == null ? "Not recorded" : value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
@@ -0,0 +1,192 @@
/*
* 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.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.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;
/*******************************************************************************
** The core messaging provider for Slack (type QBIT_SLACK, registered by the
** producer under the config's bridgeProviderName), so any process sends with
** core SendMessageAction and depends only on core (docs/DESIGN.md 4.2).
**
** RC.7 rich input maps directly to the outbox request. The owner remains the
** session user. For multiple recipients, messageId is a list of accepted IDs,
** and idempotency keys are scoped by recipient. Any refusal yields REFUSED
** with reasons; accepted recipients remain queued. Otherwise the aggregate is
** QUEUED if any row is new, or DUPLICATE if all already existed.
*******************************************************************************/
public class SlackOutboxMessagingProvider implements MessagingProviderInterface
{
/*******************************************************************************
** The provider type.
*******************************************************************************/
@Override
public String getType()
{
return (SlackOutboxMessagingProviderMetaData.TYPE);
}
/*******************************************************************************
** 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())
{
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())
{
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)
{
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
{
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));
}
/*******************************************************************************
** Refusal before any outbox row is created.
*******************************************************************************/
private static SendMessageOutput refused(String reason)
{
return (new SendMessageOutput().withStatus(SendMessageStatus.REFUSED).withReasons(List.of(reason)));
}
/*******************************************************************************
** The message text: the subject in bold, then each content body.
*******************************************************************************/
private static String compose(SendMessageInput input)
{
List<String> lines = new ArrayList<>();
if(input.getSubject() != null && !input.getSubject().isBlank())
{
lines.add("*" + input.getSubject().trim() + "*");
}
if(input.getContentList() != null)
{
for(Content content : input.getContentList())
{
if(content != null && content.getBody() != null && !content.getBody().isBlank())
{
lines.add(content.getBody());
}
}
}
return (String.join("\n", lines));
}
/*******************************************************************************
** Walk a Party or MultiParty (nested ones too) into plain addresses.
*******************************************************************************/
private static void collect(Party party, List<String> addresses)
{
if(party == null)
{
return;
}
if(party instanceof MultiParty multi)
{
if(multi.getPartyList() != null)
{
multi.getPartyList().forEach(child -> collect(child, addresses));
}
return;
}
if(party.getAddress() != null && !party.getAddress().isBlank())
{
addresses.add(party.getAddress());
}
}
}
@@ -0,0 +1,51 @@
/*
* 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.bridge;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
/*******************************************************************************
** The core messaging provider metadata for the Slack outbox. Loading this class
** registers the provider type with core's dispatcher, the same pattern core's
** own providers use.
*******************************************************************************/
public class SlackOutboxMessagingProviderMetaData extends QMessagingProviderMetaData
{
public static final String TYPE = "QBIT_SLACK";
static
{
QMessagingProviderDispatcher.registerMessagingProvider(new SlackOutboxMessagingProvider());
}
/*******************************************************************************
** Constructor: the type is fixed.
*******************************************************************************/
public SlackOutboxMessagingProviderMetaData()
{
setType(TYPE);
}
}
@@ -0,0 +1,135 @@
/*
* 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.delivery;
import java.util.List;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
/*******************************************************************************
** The alerts and notifications the dispatcher raises through core QAlerts
** (docs/DESIGN.md section 5.3), so there is no dependency on qbit-alerts. The
** signals carry no message text, address or token: only ids and fixed words.
** Core guards a missing or failing sink.
**
** The alert type names follow core's dotted pattern, which has no underscore,
** so the dead-letter type is slack.message.dead-letter.
*******************************************************************************/
public final class SlackAlerts
{
public static final String UNAUTHORIZED = "slack.delivery.unauthorized";
public static final String UNHEALTHY = "slack.workspace.unhealthy";
public static final String DEAD_LETTER = "slack.message.dead-letter";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackAlerts()
{
}
/*******************************************************************************
** The alert types, for the producer.
*******************************************************************************/
public static List<QAlertTypeMetaData> types()
{
return (List.of(
new QAlertTypeMetaData().withName(UNAUTHORIZED).withLabel("Slack delivery not authorized").withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(SlackTables.MESSAGE).withVisibilityPermission("slack.view").withSourceName("slack"),
new QAlertTypeMetaData().withName(UNHEALTHY).withLabel("Slack workspace unhealthy").withDefaultSeverity(AlertSeverity.ERROR)
.withSubjectTableName(SlackTables.WORKSPACE).withVisibilityPermission("slack.admin").withSourceName("slack"),
new QAlertTypeMetaData().withName(DEAD_LETTER).withLabel("Slack dead letters").withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(SlackTables.MESSAGE).withVisibilityPermission("slack.view").withSourceName("slack")));
}
/*******************************************************************************
** A delivery failed authorization: one alert per owner per day.
*******************************************************************************/
public static void unauthorized(String ownerUserId)
{
long day = SlackRuntime.clock().instant().getEpochSecond() / 86400;
QAlerts.raise(new AlertSignal(UNAUTHORIZED).withDedupKey("owner:" + ownerUserId + ":" + day)
.withTitle("A Slack delivery was not authorized")
.withSummary("A scheduled or queued Slack message could not be sent for its owner. Review the Slack messages."));
}
/*******************************************************************************
** A workspace has failed ten sends in a row.
*******************************************************************************/
public static void unhealthy(Integer workspaceId)
{
QAlerts.raise(new AlertSignal(UNHEALTHY).withDedupKey("workspace:" + workspaceId).withTitle("A Slack workspace is unhealthy")
.withSummary("Slack sends have failed repeatedly for a workspace. Review the Slack workspaces and messages."));
}
/*******************************************************************************
** A send succeeded after the workspace was unhealthy.
*******************************************************************************/
public static void recovered(Integer workspaceId)
{
QAlerts.resolve(UNHEALTHY, "workspace:" + workspaceId, "A Slack message was delivered");
}
/*******************************************************************************
** A message was dead-lettered: one alert per workspace per hour.
*******************************************************************************/
public static void deadLetter(Integer workspaceId)
{
long hour = SlackRuntime.clock().instant().getEpochSecond() / 3600;
QAlerts.raise(new AlertSignal(DEAD_LETTER).withDedupKey("workspace:" + workspaceId + ":" + hour).withTitle("A Slack message was dead-lettered")
.withSummary("A Slack message could not be delivered. Review the Slack messages."));
}
/*******************************************************************************
** Tell the owner, in their notifications, that a delivery failed and why.
** The reason is one of the qbit's own fixed texts, never a Slack payload.
*******************************************************************************/
public static void notifyOwner(String ownerUserId, String channelLabel, String reason)
{
QAlerts.notify(new NotificationSignal("Your Slack delivery failed")
.withBody("Your Slack delivery to " + (channelLabel == null ? "Slack" : channelLabel) + " failed: " + reason + ". Reconnect Slack.")
.withAudience(NotificationAudience.user(ownerUserId))
.withCategory(NotificationCategory.SYSTEM));
}
}
@@ -0,0 +1,251 @@
/*
* 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.delivery;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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;
import com.kingsrook.qbits.slack.outbox.SlackService;
import com.kingsrook.qbits.slack.security.SlackAuthorizer;
import com.kingsrook.qbits.slack.security.SlackOwnerSession;
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.exceptions.QException;
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).
*******************************************************************************/
public class SlackDeliveryDestination implements DeliveryDestinationInterface
{
/*******************************************************************************
** Describe the policy hosts must apply before invoking this destination.
*******************************************************************************/
public QDeliveryDestinationMetaData metadata()
{
return (new QDeliveryDestinationMetaData().withName("slack").withLabel("Slack")
.withPermissionName("slack.send").withCodeReference(new QCodeReference(SlackDeliveryDestination.class))
.withMaxClassification(DataClassification.CONFIDENTIAL)
.withAboveMaxClassification(QDeliveryDestinationMetaData.AboveMaxClassification.REFUSE)
.withMaxAttachmentBytes(SlackRuntime.config().getMaxAttachmentBytes()).withAllowBearerLinks(false));
}
/*******************************************************************************
** Detect the real SPI without registering an incompatible temporary instance.
*******************************************************************************/
public static void register()
{
try
{
Class.forName("com.kingsrook.qqq.backend.core.delivery.DeliveryDestinationsMetaData");
com.kingsrook.qqq.backend.core.logging.QLogger.getLogger(SlackDeliveryDestination.class)
.info("Slack delivery stand-in will be replaced after RC.8; host registration is deferred"); // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
}
catch(ClassNotFoundException e)
{
// RC.6 hosts use this destination directly.
}
}
/*******************************************************************************
** Offer only active connections and membership-checked targets of this user.
*******************************************************************************/
@Override
public DeliveryTargetStep describeTargets(DeliveryDestinationContext context, Map<String, Serializable> chosen) throws QException
{
String user = QContext.getQSession().getUser().getIdReference();
var connections = SlackApi.connectionsFor(user).stream().filter(c -> "ACTIVE".equals(c.status())).toList();
Integer workspace = workspace(chosen);
var connection = connections.stream().filter(c -> c.workspaceId().equals(workspace)).findFirst().orElse(null);
if(connection == null)
{
return (new DeliveryTargetStep().withName("workspaceId").withLabel("Workspace")
.withOptions(connections.stream().map(c -> new DeliveryTargetOption().withValue(c.workspaceId().toString()).withLabel(c.workspaceLabel())).toList()));
}
var targets = SlackApi.listTargets(user, workspace);
String where = chosen == null ? null : (String) chosen.get("where");
var target = targets.stream().filter(t -> t.address().equals(where)).findFirst().orElse(null);
return (new DeliveryTargetStep().withName("where").withLabel("Where")
.withOptions(targets.stream().map(t -> new DeliveryTargetOption().withValue(t.address()).withLabel(t.label())).toList())
.withComplete(target != null).withSummary(target == null ? null :
"Slack: " + ("me".equals(where) ? "DM to me" : "#" + target.label().replaceFirst("^#", "")) + " in " + connection.workspaceLabel()));
}
/*******************************************************************************
** Recheck owner identity, connection and membership without queueing anything.
*******************************************************************************/
@Override
public List<String> validate(DeliveryRequest request) throws QException
{
if(request == null || request.getOwnerUserId() == null || request.getOwnerUserId().isBlank())
{
return (List.of("A delivery owner is required"));
}
var session = QContext.getQSession();
if(session == null || !(session instanceof QSystemUserSession)
&& (session.getUser() == null || !request.getOwnerUserId().equals(session.getUser().getIdReference())))
{
return (List.of("Slack deliveries can only be sent as the signed-in user"));
}
Integer workspace = workspace(request.getTargetValues());
String where = request.getTargetValues() == null ? null : (String) request.getTargetValues().get("where");
if(workspace == null || where == null || !("me".equals(where) || where.startsWith("channel:" + workspace + "/")))
{
return (List.of("Choose a workspace and a destination"));
}
try
{
if(com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer.isInstanceEnforcementEnabled()
&& !SlackOwnerSession.create(QContext.getQInstance(), request.getOwnerUserId()).hasPermission("slack.send"))
{
return (List.of("Sending to Slack needs the slack.send permission"));
}
}
catch(QException e)
{
return (List.of("Slack couldn't verify your access"));
}
var result = new SlackAuthorizer().authorize(new SlackAuthorizer.Request()
.withOwnerUserId(request.getOwnerUserId()).withWorkspaceId(workspace).withAddress(where));
return (result.isAuthorized() ? List.of() : List.of(result.getRefusal().message()));
}
/*******************************************************************************
** Queue text and storage references atomically, after owner authorization.
*******************************************************************************/
@Override
public DeliveryResult deliver(DeliveryRequest request) throws QException
{
List<String> denied = validate(request);
if(!denied.isEmpty())
{
return (new DeliveryResult().withStatus(DeliveryResult.Status.UNAUTHORIZED).withReasons(denied));
}
if(request.getIdempotencyKey() == null || request.getIdempotencyKey().isBlank())
{
return (new DeliveryResult().withStatus(DeliveryResult.Status.REFUSED).withReasons(List.of("An idempotency key is required")));
}
return (SlackOwnerSession.call(request.getOwnerUserId(), () ->
{
Map<String, Serializable> variables = new LinkedHashMap<>();
variables.put("title", request.getTitle());
variables.put("body", request.getSummary());
variables.put("summary", request.getSummary());
variables.put("link", request.getLink());
variables.put("source", request.getSourceType());
variables.put("runTime", SlackRuntime.clock().instant().toString());
SlackSendRequest send = new SlackSendRequest().withOwnerUserId(request.getOwnerUserId())
.withWorkspaceId(workspace(request.getTargetValues())).withTo((String) request.getTargetValues().get("where"))
.withTemplateName("delivery").withVariables(variables).withIdempotencyKey(request.getIdempotencyKey())
.withContentClassification(request.getContentClassification() == null ? null : request.getContentClassification().name())
.withAttachments(request.getAttachments().stream().map(a -> new SlackSendRequest.Attachment(
a.getStorageTableName(), a.getStorageReference(), a.getFileName(), a.getSize())).toList());
SlackSendResult result = SlackService.enqueue(send);
if(result.status() == SlackSendResult.Status.REFUSED)
{
List<String> changedAccess = validate(request);
if(!changedAccess.isEmpty())
{
return (new DeliveryResult().withStatus(DeliveryResult.Status.UNAUTHORIZED).withReasons(changedAccess));
}
return (new DeliveryResult().withStatus(DeliveryResult.Status.REFUSED).withReasons(result.reasons()));
}
return (lookup(request.getIdempotencyKey()).orElseGet(() -> new DeliveryResult()
.withStatus(DeliveryResult.Status.QUEUED).withExternalReference(String.valueOf(result.messageId()))));
}));
}
/*******************************************************************************
** Reconcile only visible messages, including every attachment of a delivery.
*******************************************************************************/
@Override
public Optional<DeliveryResult> lookup(String key) throws QException
{
Optional<SlackApiMessage> found = SlackApi.getMessageByIdempotencyKey(key);
if(found.isEmpty())
{
return (Optional.empty());
}
SlackApiMessage message = found.get();
List<DeliveryResult.Status> statuses = new ArrayList<>();
statuses.add(status(message.status()));
for(var child : SlackStore.queryAsCurrent(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", message.id())))
{
if("FILE".equals(child.getValueString("operation")))
{
statuses.add(status(child.getValueString("status")));
}
}
DeliveryResult.Status outcome = statuses.contains(DeliveryResult.Status.UNAUTHORIZED) ? DeliveryResult.Status.UNAUTHORIZED
: statuses.contains(DeliveryResult.Status.FAILED) ? DeliveryResult.Status.FAILED
: statuses.stream().allMatch(s -> s == DeliveryResult.Status.DELIVERED) ? DeliveryResult.Status.DELIVERED : DeliveryResult.Status.QUEUED;
return (Optional.of(new DeliveryResult().withStatus(outcome).withExternalReference(message.id().toString())));
}
/*******************************************************************************
** Public delivery status for an outbox state, including in-flight states.
*******************************************************************************/
private static DeliveryResult.Status status(String status)
{
return (switch(status)
{
case "SENT" -> DeliveryResult.Status.DELIVERED;
case "FAILED_AUTH" -> DeliveryResult.Status.UNAUTHORIZED;
case "DEAD_LETTER", "CANCELLED", "REFUSED", "SUPPRESSED" -> DeliveryResult.Status.FAILED;
default -> DeliveryResult.Status.QUEUED;
});
}
/*******************************************************************************
** Picker values arrive as either JSON numbers or strings.
*******************************************************************************/
private static Integer workspace(Map<String, Serializable> values)
{
try
{
return (values == null || values.get("workspaceId") == null ? null : Integer.valueOf(values.get("workspaceId").toString()));
}
catch(NumberFormatException e)
{
return (null);
}
}
}
@@ -0,0 +1,893 @@
/*
* 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.delivery;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.security.AuthorizedSend;
import com.kingsrook.qbits.slack.security.SlackAuthorization;
import com.kingsrook.qbits.slack.security.SlackAuthorizer;
import com.kingsrook.qbits.slack.security.SlackRedaction;
import com.kingsrook.qbits.slack.security.SlackRefusal;
import com.kingsrook.qbits.slack.security.SlackTokens;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qbits.slack.transport.SlackClient;
import com.kingsrook.qbits.slack.transport.SlackErrorClassifier;
import com.kingsrook.qbits.slack.transport.SlackResponse;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The Slack outbox dispatcher (docs/DESIGN.md sections 4.1, 5.2 and 5.3), run
** by a scheduled process. It mirrors qbit-email's: a claim, a lease, attempt
** rows, and no network call inside a database transaction.
**
** For each due message:
** 1. Client-side limits (one post a second per channel, ratePerMinute per
** workspace, one probe per five minutes for an unhealthy workspace). Waiting
** here leaves the message untouched: it isn't an attempt.
** 2. Claim. Inside a transaction that locks the message row, the runner inserts
** a slack_message_attempt (messageId, attemptNumber). That pair is unique,
** so one runner wins. The message becomes SENDING with a lease.
** 3. Re-authorize as the owner (SlackAuthorizer), exactly as at queue time,
** because access may have changed. Only then is the one chosen row's token
** read and decrypted. A refusal is FAILED_AUTH, the owner is notified, and
** nothing is posted. Another row's token is never used.
** 4. Expired lease: the previous runner may have posted and died. Before
** posting again, conversations.history is searched for our message metadata
** (event_payload.id equal to the message id). Found: record that ts as SENT.
** History unreadable: RETRY_WAIT, and nothing is posted.
** 5. Post (chat.postMessage) or update (chat.update), then record the outcome.
**
** The attempt number is the count of attempt rows plus one, so it only grows.
** The message's attemptCount is the budget: how many attempts have been spent.
** A 429 hands the spent attempt back: attemptCount returns to what it was
** before the claim, the attempt row is marked RATE_LIMITED, and the message
** waits for Retry-After (at most 15 minutes). The next claim still gets a new
** attempt number, so the unique pair is never reused, and maxAttempts is never
** consumed by rate limiting.
*******************************************************************************/
public class SlackDispatcher
{
private static final QLogger LOG = QLogger.getLogger(SlackDispatcher.class);
public static final List<String> DUE = List.of("QUEUED", "RETRY_WAIT");
private static final int MAX_HISTORY_PAGES = 5;
private static final int UNHEALTHY_AFTER = 10;
private static final int MAX_RETRY_AFTER_SECONDS = 900;
private static final int DEFAULT_RATE_PER_MINUTE = 50;
private static final int HISTORY_LOOKBACK_SECONDS = 120;
private final SlackAuthorizer authorizer = new SlackAuthorizer();
private final String runner = SlackRuntime.nodeId() + "/" + UUID.randomUUID();
/*******************************************************************************
** The result of one message attempt.
*******************************************************************************/
public enum SendResult
{
SENT,
RETRY_WAIT,
DEAD_LETTER,
FAILED_AUTH,
RATE_LIMITED,
NOT_SENT
}
/*******************************************************************************
** Counts from one dispatch pass.
*******************************************************************************/
public record RunSummary(int sent, int failed, int skipped)
{
}
/*******************************************************************************
** What a successful claim returns: the message as claimed, the attempt row,
** the budget spent before this claim, and whether a lease had expired (so a
** reconcile is needed before posting).
*******************************************************************************/
private record Claim(QRecord message, Integer attemptId, int previousCount, boolean reconcile)
{
}
/*******************************************************************************
** How Slack answered, in the terms the dispatcher acts on.
*******************************************************************************/
private enum Result
{
SENT,
RATE_LIMITED,
TRANSIENT,
PERMANENT,
AUTH
}
/*******************************************************************************
** The result of working on a claimed message. reason is fit for a person (and
** for lastError); it never holds a token or a Slack payload.
*******************************************************************************/
private record Outcome(Result result, String code, String reason, Integer httpStatus, Integer retryAfter, String ts, String channelId, SlackEnums.PostAs postedAs, Integer connectionId, boolean revoke)
{
}
/*******************************************************************************
** Dispatch the due messages, up to the configured batch size.
*******************************************************************************/
public RunSummary runOnce() throws QException
{
SlackQBitConfig config = SlackRuntime.config();
if(config == null)
{
return (new RunSummary(0, 0, 0));
}
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, "QUEUED", "RETRY_WAIT", "SENDING"))
.withCriteria(new QFilterCriteria("nextAttemptDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, SlackRuntime.clock().instant()))
.withOrderBy(new QFilterOrderBy("id")).withLimit(config.getBatchSize());
int sent = 0;
int failed = 0;
int skipped = 0;
for(QRecord row : SlackStore.query(SlackTables.MESSAGE, filter))
{
Integer id = row.getValueInteger("id");
try
{
SendResult result = attempt(id, false);
if(result == SendResult.SENT)
{
sent++;
}
else if(result == SendResult.NOT_SENT || result == SendResult.RATE_LIMITED)
{
skipped++;
}
else
{
failed++;
}
}
catch(QException | RuntimeException e)
{
failed++;
LOG.warn("Slack attempt failed", logPair("messageId", id), logPair("error", SlackRedaction.scrub(e.getMessage())));
}
}
return (new RunSummary(sent, failed, skipped));
}
/*******************************************************************************
** Send one message now, ignoring its due time (but not the limits or lease).
*******************************************************************************/
public SendResult sendNow(Integer id) throws QException
{
return (attempt(id, true));
}
/*******************************************************************************
** Limits, claim, work, and record the outcome.
*******************************************************************************/
protected SendResult attempt(Integer id, boolean immediate) throws QException
{
SlackQBitConfig config = SlackRuntime.config();
QRecord initial = config == null ? null : SlackStore.get(SlackTables.MESSAGE, id);
if(initial == null)
{
return (SendResult.NOT_SENT);
}
Integer workspaceId = initial.getValueInteger("workspaceId");
QRecord workspace = SlackStore.get(SlackTables.WORKSPACE, workspaceId);
Instant now = SlackRuntime.clock().instant();
String channelKey = initial.getValueString("slackChannelId") != null ? initial.getValueString("slackChannelId") : "dm:" + initial.getValueString("ownerUserId");
boolean unhealthy = workspace != null && SlackEnums.HealthStatus.UNHEALTHY.name().equals(workspace.getValueString("health"));
if(!immediate && workspace != null)
{
Integer rate = workspace.getValueInteger("ratePerMinute");
if(SlackRuntime.limiter().waitMillis(workspaceId, channelKey, rate == null ? DEFAULT_RATE_PER_MINUTE : rate, now) > 0
|| (unhealthy && SlackRuntime.limiter().probeWaitMillis(workspaceId, now) > 0))
{
return (SendResult.NOT_SENT);
}
}
Claim claim = claim(id, immediate, config);
if(claim == null)
{
return (SendResult.NOT_SENT);
}
SlackRuntime.limiter().record(workspaceId, channelKey, now);
if(unhealthy)
{
SlackRuntime.limiter().recordProbe(workspaceId, now);
}
Outcome outcome;
try
{
outcome = work(config, claim);
}
catch(QException | RuntimeException e)
{
LOG.warn("Slack delivery work failed", logPair("messageId", id), logPair("error", SlackRedaction.scrub(e.getMessage())));
outcome = new Outcome(Result.TRANSIENT, "dispatch_error", "An unexpected error stopped the delivery", null, null, null, null, null, null, false);
}
return (finish(config, claim, outcome));
}
/*******************************************************************************
** Claim a message: lock its row, check it is still due (or its lease
** expired), insert the unique attempt row, and set SENDING with a lease. The
** transaction is committed before any network call.
*******************************************************************************/
private Claim claim(Integer id, boolean immediate, SlackQBitConfig config) throws QException
{
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(SlackTables.MESSAGE)))
{
Instant time = SlackRuntime.clock().instant();
///////////////////////////////////////////////////////////////////////
// a write takes the row lock: a second runner waits here, then sees //
// SENDING with a live lease //
///////////////////////////////////////////////////////////////////////
SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("modifyDate", time), tx);
QRecord message = SlackStore.get(SlackTables.MESSAGE, id, tx);
if(message == null)
{
return (null);
}
String status = message.getValueString("status");
Integer spent = message.getValueInteger("attemptCount");
int previous = spent == null ? 0 : spent;
boolean reconcile = false;
Integer workspaceId = message.getValueInteger("workspaceId");
if(SlackEnums.MessageStatus.SENDING.name().equals(status))
{
Instant lease = message.getValueInstant("leaseExpiresDate");
if(lease != null && lease.isAfter(time))
{
return (null);
}
reconcile = true;
expire(id, tx, time);
if(previous >= config.getMaxAttempts())
{
SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", SlackEnums.MessageStatus.DEAD_LETTER.name())
.withValue("deadLetterDate", time).withValue("leaseExpiresDate", null).withValue("nextAttemptDate", null).withValue("lastError", "Attempt lease expired"), tx);
tx.commit();
alert(() -> SlackAlerts.deadLetter(workspaceId));
return (null);
}
}
else if(!DUE.contains(status))
{
return (null);
}
Instant next = message.getValueInstant("nextAttemptDate");
if(!immediate && next != null && next.isAfter(time))
{
return (null);
}
int attemptNumber = SlackStore.count(SlackTables.ATTEMPT, SlackStore.equal("messageId", id), tx) + 1;
Instant lease = time.plusSeconds(config.getLeaseSeconds());
QRecord inserted;
try
{
inserted = SlackStore.insert(SlackTables.ATTEMPT, new QRecord().withValue("messageId", id).withValue("attemptNumber", attemptNumber)
.withValue("outcome", SlackEnums.AttemptOutcome.CLAIMED.name()).withValue("claimedBy", runner).withValue("startDate", time), tx);
}
catch(QException e)
{
/////////////////////////////////////////////////////////////////////
// the unique (messageId, attemptNumber) pair: another runner won //
/////////////////////////////////////////////////////////////////////
return (null);
}
if(inserted.getValueInteger("id") == null || (inserted.getErrors() != null && !inserted.getErrors().isEmpty()))
{
return (null);
}
SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("status", SlackEnums.MessageStatus.SENDING.name())
.withValue("attemptCount", previous + 1).withValue("leaseExpiresDate", lease).withValue("nextAttemptDate", lease), tx);
tx.commit();
return (new Claim(message, inserted.getValueInteger("id"), previous, reconcile));
}
}
/*******************************************************************************
** Mark the abandoned attempt rows of a message whose lease expired.
*******************************************************************************/
private static void expire(Integer messageId, QBackendTransaction tx, Instant now) throws QException
{
for(QRecord attempt : SlackStore.query(SlackTables.ATTEMPT, SlackStore.equal("messageId", messageId)
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, SlackEnums.AttemptOutcome.CLAIMED.name())), tx))
{
SlackStore.update(SlackTables.ATTEMPT, new QRecord().withValue("id", attempt.getValueInteger("id"))
.withValue("outcome", SlackEnums.AttemptOutcome.LEASE_EXPIRED.name()).withValue("endDate", now).withValue("error", "Attempt lease expired"), tx);
}
}
/*******************************************************************************
** Everything between the claim and the record of the outcome: authorize as
** the owner, read the one token, find the channel, reconcile after a crash,
** and call Slack.
*******************************************************************************/
private Outcome work(SlackQBitConfig config, Claim claim) throws QException
{
QRecord message = claim.message();
Integer messageId = message.getValueInteger("id");
Integer workspaceId = message.getValueInteger("workspaceId");
String owner = message.getValueString("ownerUserId");
boolean update = SlackEnums.Operation.UPDATE.name().equals(message.getValueString("operation"));
///////////////////////////////////////////////////////////////////////////////
// authorize again, as the owner: access may have changed since queue time. //
// the posting identity chosen at queue time is kept (an update must keep it, //
// because Slack only lets a token edit its own messages), so a lost identity //
// is a failed authorization rather than a silent switch to another token //
///////////////////////////////////////////////////////////////////////////////
String queuedIdentity = message.getValueString("postAs");
SlackEnums.PostAs fixedIdentity = queuedIdentity == null ? null : SlackEnums.PostAs.valueOf(queuedIdentity);
SlackAuthorization authorization = SlackStore.asSystem(() -> authorizer.authorize(new SlackAuthorizer.Request()
.withOwnerUserId(owner).withAddress(message.getValueString("targetRef")).withWorkspaceId(workspaceId).withPostAs(fixedIdentity)));
if(!authorization.isAuthorized())
{
return (refusalOutcome(authorization.getRefusal()));
}
AuthorizedSend send = authorization.getSend();
if(!workspaceId.equals(send.workspaceId()))
{
return (authFailure("workspace_changed", "That Slack workspace is no longer available to you", null));
}
String token;
try
{
token = send.postAs() == SlackEnums.PostAs.BOT ? SlackTokens.botToken(config, workspaceId) : SlackTokens.userToken(config, send.connectionId());
}
catch(QException e)
{
return (authFailure("token_unreadable", "Your Slack connection can't be used", send));
}
SlackClient client = SlackRuntime.client(config);
String channel = send.slackChannelId() != null ? send.slackChannelId() : message.getValueString("slackChannelId");
if(channel == null)
{
SlackResponse open = client.conversationsOpen(token, send.slackUserId());
if(!open.isOk() || open.getJson().optJSONObject("channel") == null)
{
return (failure(open, send, null));
}
channel = open.getJson().getJSONObject("channel").optString("id", null);
}
if(SlackEnums.Operation.FILE.name().equals(message.getValueString("operation")))
{
return (file(client, token, channel, claim, send));
}
if(claim.reconcile() && !update)
{
Outcome reconciled = reconcile(client, token, channel, message, send);
if(reconciled != null)
{
return (reconciled);
}
}
SlackResponse response = update
? client.chatUpdate(token, channel, message.getValueString("slackTs"), message.getValueString("text"), message.getValueString("blocksJson"))
: client.chatPostMessage(token, channel, message.getValueString("text"), message.getValueString("blocksJson"), message.getValueString("threadTs"), messageId, message.getValueString("idempotencyKey"));
if(!response.isOk())
{
return (failure(response, send, channel));
}
String ts = update ? message.getValueString("slackTs") : response.getString("ts");
return (new Outcome(Result.SENT, null, null, response.getHttpStatus(), null, ts, response.getString("channel") == null ? channel : response.getString("channel"), send.postAs(), send.connectionId(), false));
}
/*******************************************************************************
** Stream storage as the owner, then complete the external upload. Once an
** upload id exists, retries only reconcile: a lost completion response must
** never create a second file. Unconfirmed uploads remain RETRY_WAIT.
*******************************************************************************/
private Outcome file(SlackClient client, String token, String channel, Claim claim, AuthorizedSend send) throws QException
{
QRecord message = claim.message();
Integer id = message.getValueInteger("id");
if(claim.reconcile() || Boolean.TRUE.equals(message.getValueBoolean("fileReconcileRequired")) || message.getValueString("slackFileId") != null)
{
SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("fileReconcileRequired", true), null);
return (reconcileFile(client, token, channel, message, send));
}
Long size = message.getValueLong("attachmentBytes");
if(size == null || size <= 0 || size > SlackRuntime.config().getMaxAttachmentBytes())
{
return (new Outcome(Result.PERMANENT, "file_size", "The attachment exceeds the Slack size limit", null, null, null, channel, send.postAs(), send.connectionId(), false));
}
return (com.kingsrook.qbits.slack.security.SlackOwnerSession.call(message.getValueString("ownerUserId"), () ->
{
try(java.io.InputStream bytes = new com.kingsrook.qqq.backend.core.actions.tables.StorageAction().getInputStream(
new com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput(message.getValueString("attachmentStorageTable"))
.withReference(message.getValueString("attachmentStorageRef"))))
{
SlackResponse allocated = client.filesGetUploadURLExternal(token, message.getValueString("attachmentName"), size);
if(!allocated.isOk())
{
return (failure(allocated, send, channel));
}
String fileId = allocated.getString("file_id");
String url = allocated.getString("upload_url");
if(fileId == null || url == null)
{
return (failure(SlackResponse.failure(0, "invalid_upload_response"), send, channel));
}
SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", id).withValue("slackFileId", fileId), null);
SlackResponse uploaded = client.uploadExternal(url, bytes, size);
if(!uploaded.isOk())
{
return (failure(uploaded, send, channel));
}
SlackResponse completed = client.filesCompleteUploadExternal(token, fileId,
message.getValueString("attachmentName") + " qqq:" + id, channel, message.getValueString("threadTs"));
if(!completed.isOk())
{
return (failure(completed, send, channel));
}
return (new Outcome(Result.SENT, null, null, completed.getHttpStatus(), null, null, channel, send.postAs(), send.connectionId(), false));
}
catch(java.io.IOException e)
{
return (failure(SlackResponse.failure(0, "storage_read_failed"), send, channel));
}
}));
}
/*******************************************************************************
** A missing marker is inconclusive, including when Slack is still processing
** the upload. Every retry continues reconciliation instead of uploading again.
*******************************************************************************/
private Outcome reconcileFile(SlackClient client, String token, String channel, QRecord message, AuthorizedSend send) throws QException
{
var attempts = SlackStore.query(SlackTables.ATTEMPT, SlackStore.equal("messageId", message.getValueInteger("id"))
.withOrderBy(new QFilterOrderBy("startDate")).withLimit(1));
Instant since = attempts.isEmpty() ? null : attempts.getFirst().getValueInstant("startDate");
String oldest = since == null ? "0" : String.valueOf(since.getEpochSecond() - HISTORY_LOOKBACK_SECONDS);
for(int page = 1; page <= MAX_HISTORY_PAGES; page++)
{
SlackResponse response = client.filesList(token, channel, oldest, page);
if(!response.isOk())
{
if(SlackErrorClassifier.classify(response) == SlackErrorClassifier.Kind.AUTH
|| SlackErrorClassifier.classify(response) == SlackErrorClassifier.Kind.RATE_LIMITED)
{
return (failure(response, send, channel));
}
break;
}
JSONArray files = response.getJson().optJSONArray("files");
for(int i = 0; files != null && i < files.length(); i++)
{
JSONObject found = files.getJSONObject(i);
if(found.optString("title").endsWith(" qqq:" + message.getValueInteger("id")) && !found.optString("id").isBlank())
{
SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", message.getValueInteger("id")).withValue("slackFileId", found.getString("id")), null);
return (new Outcome(Result.SENT, null, null, response.getHttpStatus(), null, null, channel, send.postAs(), send.connectionId(), false));
}
}
JSONObject paging = response.getJson().optJSONObject("paging");
if(paging == null || page >= paging.optInt("pages", 1))
{
break;
}
}
return (new Outcome(Result.TRANSIENT, "file_unconfirmed", "Slack hasn't confirmed the earlier file upload", null, null, null, channel, send.postAs(), send.connectionId(), false));
}
/*******************************************************************************
** After an expired lease: look in the channel's history for a message that
** carries our id. Returns SENT with its ts when found, a failure when the
** history can't be read (so nothing is posted blind), and null when it is
** certain the message never went out.
*******************************************************************************/
private Outcome reconcile(SlackClient client, String token, String channel, QRecord message, AuthorizedSend send) throws QException
{
List<QRecord> attempts = SlackStore.query(SlackTables.ATTEMPT, SlackStore.equal("messageId", message.getValueInteger("id")).withOrderBy(new QFilterOrderBy("startDate")).withLimit(1));
Instant since = attempts.isEmpty() || attempts.get(0).getValueInstant("startDate") == null ? message.getValueInstant("createDate") : attempts.get(0).getValueInstant("startDate");
String oldest = since == null ? null : String.valueOf(since.getEpochSecond() - HISTORY_LOOKBACK_SECONDS);
String cursor = null;
for(int page = 0; page < MAX_HISTORY_PAGES; page++)
{
SlackResponse history = client.conversationsHistory(token, channel, oldest, cursor);
if(!history.isOk())
{
///////////////////////////////////////////////////////////////////////
// never post blind: any failure here is a wait, unless Slack says //
// the token is dead or asks us to slow down //
///////////////////////////////////////////////////////////////////////
SlackErrorClassifier.Kind kind = SlackErrorClassifier.classify(history);
if(kind == SlackErrorClassifier.Kind.RATE_LIMITED || (kind == SlackErrorClassifier.Kind.AUTH && SlackErrorClassifier.revokesAccess(history.getError())))
{
return (failure(history, send, channel));
}
return (new Outcome(Result.TRANSIENT, "history_unavailable", "Slack history couldn't be read to confirm the earlier attempt", history.getHttpStatus(), null, null, channel, send.postAs(), send.connectionId(), false));
}
JSONArray messages = history.getJson().optJSONArray("messages");
for(int i = 0; messages != null && i < messages.length(); i++)
{
JSONObject found = ours(messages.optJSONObject(i), message.getValueInteger("id"));
if(found != null)
{
return (new Outcome(Result.SENT, null, null, history.getHttpStatus(), null, found.optString("ts", null), channel, send.postAs(), send.connectionId(), false));
}
}
JSONObject metadata = history.getJson().optJSONObject("response_metadata");
cursor = metadata == null ? null : metadata.optString("next_cursor", null);
if(cursor == null || cursor.isEmpty())
{
break;
}
}
return (null);
}
/*******************************************************************************
** The history message if it carries our qqq_message metadata for this id.
*******************************************************************************/
private static JSONObject ours(JSONObject message, Integer messageId)
{
JSONObject metadata = message == null ? null : message.optJSONObject("metadata");
JSONObject payload = metadata == null || !SlackClient.EVENT_TYPE.equals(metadata.optString("event_type")) ? null : metadata.optJSONObject("event_payload");
return (payload != null && String.valueOf(messageId).equals(String.valueOf(payload.opt("id"))) ? message : null);
}
/*******************************************************************************
** The outcome for a refused authorization at send time. A refusal that came
** from a Slack outage is a retry or a wait, not a failure of the owner's
** access; a channel that Slack says is gone is permanent; the rest is the
** owner losing access.
*******************************************************************************/
private static Outcome refusalOutcome(SlackRefusal refusal)
{
String code = refusal.slackError() != null ? refusal.slackError() : refusal.reason().name().toLowerCase(java.util.Locale.ROOT);
if(refusal.reason() == SlackRefusal.Reason.SLACK_ERROR && refusal.slackKind() == SlackErrorClassifier.Kind.RATE_LIMITED)
{
return (new Outcome(Result.RATE_LIMITED, code, refusal.message(), 429, refusal.retryAfterSeconds(), null, null, null, null, false));
}
if(refusal.reason() == SlackRefusal.Reason.SLACK_ERROR)
{
return (new Outcome(Result.TRANSIENT, code, refusal.message(), null, null, null, null, null, null, false));
}
if(refusal.slackKind() == SlackErrorClassifier.Kind.PERMANENT)
{
return (new Outcome(Result.PERMANENT, code, refusal.message(), null, null, null, null, null, null, false));
}
return (new Outcome(Result.AUTH, code, refusal.message(), null, null, null, null, null, null, false));
}
/*******************************************************************************
** An authorization failure that wasn't a Slack answer.
*******************************************************************************/
private static Outcome authFailure(String code, String reason, AuthorizedSend send)
{
return (new Outcome(Result.AUTH, code, reason, null, null, null, null, send == null ? null : send.postAs(), send == null ? null : send.connectionId(), false));
}
/*******************************************************************************
** The outcome for a failed Slack call, by the classifier's verdict.
*******************************************************************************/
private static Outcome failure(SlackResponse response, AuthorizedSend send, String channel)
{
SlackErrorClassifier.Kind kind = SlackErrorClassifier.classify(response);
String code = response.getError();
SlackEnums.PostAs as = send == null ? null : send.postAs();
Integer conn = send == null ? null : send.connectionId();
if(kind == SlackErrorClassifier.Kind.RATE_LIMITED)
{
return (new Outcome(Result.RATE_LIMITED, "ratelimited", "Slack asked us to wait", response.getHttpStatus(), response.getRetryAfterSeconds(), null, channel, as, conn, false));
}
if(kind == SlackErrorClassifier.Kind.TRANSIENT)
{
return (new Outcome(Result.TRANSIENT, code, "Slack was temporarily unavailable (" + code + ")", response.getHttpStatus(), null, null, channel, as, conn, false));
}
if(kind == SlackErrorClassifier.Kind.AUTH)
{
boolean revoke = SlackErrorClassifier.revokesAccess(code);
return (new Outcome(Result.AUTH, code, revoke ? "Slack access was revoked" : "Slack refused the request (" + code + ")", response.getHttpStatus(), null, null, channel, as, conn, revoke));
}
return (new Outcome(Result.PERMANENT, code, "Slack rejected the message (" + code + ")", response.getHttpStatus(), null, null, channel, as, conn, false));
}
/*******************************************************************************
** Record the outcome in one transaction, fenced by the claim (a late finish
** of an expired attempt changes nothing), then do the side effects that must
** only follow a commit: alerts, notifications and revocation.
*******************************************************************************/
private SendResult finish(SlackQBitConfig config, Claim claim, Outcome outcome) throws QException
{
List<Runnable> afterCommit = new ArrayList<>();
SendResult sendResult;
QRecord message = claim.message();
Integer id = message.getValueInteger("id");
Integer workspaceId = message.getValueInteger("workspaceId");
String owner = message.getValueString("ownerUserId");
try(QBackendTransaction tx = QBackendTransaction.openFor(new UpdateInput(SlackTables.MESSAGE)))
{
QRecord current = SlackStore.get(SlackTables.MESSAGE, id, tx);
int counted = claim.previousCount() + 1;
if(current == null || !SlackEnums.MessageStatus.SENDING.name().equals(current.getValueString("status")) || !Integer.valueOf(counted).equals(current.getValueInteger("attemptCount")))
{
return (SendResult.NOT_SENT);
}
Instant now = SlackRuntime.clock().instant();
String text = SlackRedaction.scrub(outcome.reason());
SlackStore.update(SlackTables.ATTEMPT, new QRecord().withValue("id", claim.attemptId()).withValue("outcome", attemptOutcome(outcome.result()))
.withValue("errorCode", outcome.code()).withValue("httpStatus", outcome.httpStatus()).withValue("retryAfterSeconds", outcome.retryAfter())
.withValue("postedAs", outcome.postedAs() == null ? null : outcome.postedAs().name()).withValue("endDate", now).withValue("error", text), tx);
QRecord update = new QRecord().withValue("id", id).withValue("leaseExpiresDate", null).withValue("lastErrorCode", outcome.code()).withValue("lastError", text);
if(outcome.result() == Result.SENT)
{
sendResult = SendResult.SENT;
update.withValue("status", SlackEnums.MessageStatus.SENT.name()).withValue("sentDate", now).withValue("slackTs", outcome.ts()).withValue("slackChannelId", outcome.channelId())
.withValue("nextAttemptDate", null).withValue("lastErrorCode", null).withValue("lastError", null);
if(outcome.postedAs() != null)
{
update.withValue("postAs", outcome.postedAs().name()).withValue("connectionId", outcome.connectionId());
}
}
else if(outcome.result() == Result.RATE_LIMITED)
{
///////////////////////////////////////////////////////////////////////
// not an attempt: hand the spent attempt back, and wait //
///////////////////////////////////////////////////////////////////////
sendResult = SendResult.RATE_LIMITED;
int seconds = Math.max(1, Math.min(MAX_RETRY_AFTER_SECONDS, outcome.retryAfter() == null ? 30 : outcome.retryAfter()));
update.withValue("status", SlackEnums.MessageStatus.RETRY_WAIT.name()).withValue("attemptCount", claim.previousCount()).withValue("nextAttemptDate", now.plusSeconds(seconds))
.withValue("lastError", "Slack asked us to wait " + seconds + " seconds");
}
else if(outcome.result() == Result.TRANSIENT && counted < config.getMaxAttempts())
{
sendResult = SendResult.RETRY_WAIT;
update.withValue("status", SlackEnums.MessageStatus.RETRY_WAIT.name()).withValue("nextAttemptDate", now.plusSeconds(60L * config.retryMinutesAfterAttempt(counted)));
}
else if(outcome.result() == Result.AUTH)
{
sendResult = SendResult.FAILED_AUTH;
update.withValue("status", SlackEnums.MessageStatus.FAILED_AUTH.name()).withValue("nextAttemptDate", null);
}
else
{
sendResult = SendResult.DEAD_LETTER;
update.withValue("status", SlackEnums.MessageStatus.DEAD_LETTER.name()).withValue("deadLetterDate", now).withValue("nextAttemptDate", null);
}
SlackStore.update(SlackTables.MESSAGE, update, tx);
///////////////////////////////////////////////////////////////////////
// threads: replies waiting on this message are released, or dropped //
///////////////////////////////////////////////////////////////////////
if(sendResult == SendResult.SENT || sendResult == SendResult.DEAD_LETTER || sendResult == SendResult.FAILED_AUTH)
{
for(QRecord child : SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("parentMessageId", id)
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, SlackEnums.MessageStatus.WAITING_PARENT.name())), tx))
{
QRecord childUpdate = new QRecord().withValue("id", child.getValueInteger("id"));
if(sendResult == SendResult.SENT)
{
childUpdate.withValue("status", SlackEnums.MessageStatus.QUEUED.name()).withValue("threadTs", outcome.ts()).withValue("nextAttemptDate", now);
}
else
{
childUpdate.withValue("status", SlackEnums.MessageStatus.CANCELLED.name()).withValue("lastError", "The message this replies to was not delivered").withValue("nextAttemptDate", null);
}
SlackStore.update(SlackTables.MESSAGE, childUpdate, tx);
}
}
trackHealth(workspaceId, outcome, tx, afterCommit);
tx.commit();
}
///////////////////////////////////////////////////////////////////////////
// only now, after the commit: revoke, alert and tell the owner. none of //
// it can change what was recorded //
///////////////////////////////////////////////////////////////////////////
if(outcome.result() == Result.AUTH)
{
if(outcome.revoke())
{
afterCommit.add(() ->
{
try
{
SlackAuthorizer.markRevoked(outcome.postedAs(), workspaceId, outcome.connectionId());
}
catch(QException e)
{
LOG.warn("Could not mark a Slack connection revoked", logPair("messageId", id));
}
});
}
if(message.getValueString("slackChannelId") != null)
{
SlackRuntime.membership().invalidate(workspaceId, message.getValueString("slackChannelId"));
}
afterCommit.add(() -> SlackAlerts.unauthorized(owner));
if(!SlackAuthorizer.isOwnerless(owner))
{
String reason = outcome.reason() == null ? "access was lost" : outcome.reason().replaceAll("\\.$", "");
afterCommit.add(() -> SlackAlerts.notifyOwner(owner, message.getValueString("channelLabel"), reason));
}
}
if(sendResult == SendResult.DEAD_LETTER)
{
afterCommit.add(() -> SlackAlerts.deadLetter(workspaceId));
}
afterCommit.forEach(SlackDispatcher::alert);
LOG.info("Slack attempt completed", logPair("messageId", id), logPair("status", sendResult.name()));
return (sendResult);
}
/*******************************************************************************
** Workspace health: ten transient failures in a row make it UNHEALTHY, and a
** success makes it HEALTHY again. Other failures leave the streak alone.
*******************************************************************************/
private static void trackHealth(Integer workspaceId, Outcome outcome, QBackendTransaction tx, List<Runnable> afterCommit) throws QException
{
QRecord workspace = SlackStore.get(SlackTables.WORKSPACE, workspaceId, tx);
if(workspace == null)
{
return;
}
boolean wasUnhealthy = SlackEnums.HealthStatus.UNHEALTHY.name().equals(workspace.getValueString("health"));
Integer streak = workspace.getValueInteger("consecutiveFailures");
int failures = streak == null ? 0 : streak;
if(outcome.result() == Result.SENT && (failures > 0 || wasUnhealthy))
{
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", workspaceId).withValue("consecutiveFailures", 0).withValue("health", SlackEnums.HealthStatus.HEALTHY.name()), tx);
if(wasUnhealthy)
{
afterCommit.add(() -> SlackAlerts.recovered(workspaceId));
}
}
else if(outcome.result() == Result.TRANSIENT)
{
boolean unhealthy = failures + 1 >= UNHEALTHY_AFTER;
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", workspaceId).withValue("consecutiveFailures", failures + 1)
.withValue("health", unhealthy || wasUnhealthy ? SlackEnums.HealthStatus.UNHEALTHY.name() : SlackEnums.HealthStatus.HEALTHY.name()), tx);
if(unhealthy && !wasUnhealthy)
{
afterCommit.add(() -> SlackAlerts.unhealthy(workspaceId));
}
}
}
/*******************************************************************************
** The attempt row's outcome for a result.
*******************************************************************************/
private static String attemptOutcome(Result result)
{
return (switch(result)
{
case SENT -> SlackEnums.AttemptOutcome.SENT.name();
case RATE_LIMITED -> SlackEnums.AttemptOutcome.RATE_LIMITED.name();
case TRANSIENT -> SlackEnums.AttemptOutcome.TRANSIENT_FAILURE.name();
case PERMANENT -> SlackEnums.AttemptOutcome.PERMANENT_FAILURE.name();
case AUTH -> SlackEnums.AttemptOutcome.AUTH_FAILURE.name();
});
}
/*******************************************************************************
** Alerts and notifications must never change the result of a committed
** delivery.
*******************************************************************************/
private static void alert(Runnable notification)
{
try
{
notification.run();
}
catch(RuntimeException e)
{
LOG.warn("Slack alert unavailable");
}
}
}
@@ -0,0 +1,193 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
/*******************************************************************************
** DeliveryAttachment.
*******************************************************************************/
public class DeliveryAttachment implements java.io.Serializable
{
private String fileName;
private String mimeType;
private Long size;
private String storageTableName;
private String storageReference;
/*******************************************************************************
** Get fileName.
*******************************************************************************/
public String getFileName()
{
return (fileName);
}
/*******************************************************************************
** Set fileName.
*******************************************************************************/
public void setFileName(String fileName)
{
this.fileName = fileName;
}
/*******************************************************************************
** With fileName.
*******************************************************************************/
public DeliveryAttachment withFileName(String fileName)
{
this.fileName = fileName;
return (this);
}
/*******************************************************************************
** Get mimeType.
*******************************************************************************/
public String getMimeType()
{
return (mimeType);
}
/*******************************************************************************
** Set mimeType.
*******************************************************************************/
public void setMimeType(String mimeType)
{
this.mimeType = mimeType;
}
/*******************************************************************************
** With mimeType.
*******************************************************************************/
public DeliveryAttachment withMimeType(String mimeType)
{
this.mimeType = mimeType;
return (this);
}
/*******************************************************************************
** Get size.
*******************************************************************************/
public Long getSize()
{
return (size);
}
/*******************************************************************************
** Set size.
*******************************************************************************/
public void setSize(Long size)
{
this.size = size;
}
/*******************************************************************************
** With size.
*******************************************************************************/
public DeliveryAttachment withSize(Long size)
{
this.size = size;
return (this);
}
/*******************************************************************************
** Get storageTableName.
*******************************************************************************/
public String getStorageTableName()
{
return (storageTableName);
}
/*******************************************************************************
** Set storageTableName.
*******************************************************************************/
public void setStorageTableName(String storageTableName)
{
this.storageTableName = storageTableName;
}
/*******************************************************************************
** With storageTableName.
*******************************************************************************/
public DeliveryAttachment withStorageTableName(String storageTableName)
{
this.storageTableName = storageTableName;
return (this);
}
/*******************************************************************************
** Get storageReference.
*******************************************************************************/
public String getStorageReference()
{
return (storageReference);
}
/*******************************************************************************
** Set storageReference.
*******************************************************************************/
public void setStorageReference(String storageReference)
{
this.storageReference = storageReference;
}
/*******************************************************************************
** With storageReference.
*******************************************************************************/
public DeliveryAttachment withStorageReference(String storageReference)
{
this.storageReference = storageReference;
return (this);
}
}
@@ -0,0 +1,65 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
/*******************************************************************************
** DeliveryDestinationContext.
*******************************************************************************/
public class DeliveryDestinationContext implements java.io.Serializable
{
private QDeliveryDestinationMetaData destination;
/*******************************************************************************
** Get destination.
*******************************************************************************/
public QDeliveryDestinationMetaData getDestination()
{
return (destination);
}
/*******************************************************************************
** Set destination.
*******************************************************************************/
public void setDestination(QDeliveryDestinationMetaData destination)
{
this.destination = destination;
}
/*******************************************************************************
** With destination.
*******************************************************************************/
public DeliveryDestinationContext withDestination(QDeliveryDestinationMetaData destination)
{
this.destination = destination;
return (this);
}
}
@@ -0,0 +1,69 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Owner-scoped output delivery provider.
*******************************************************************************/
public interface DeliveryDestinationInterface
{
/*******************************************************************************
** Describe the next target in the current owner session.
*******************************************************************************/
DeliveryTargetStep describeTargets(DeliveryDestinationContext context, Map<String, Serializable> chosen) throws QException;
/*******************************************************************************
** Validate a complete target before sending.
*******************************************************************************/
default List<String> validate(DeliveryRequest request) throws QException
{
return (List.of());
}
/*******************************************************************************
** Deliver idempotently using the request key.
*******************************************************************************/
DeliveryResult deliver(DeliveryRequest request) throws QException;
/*******************************************************************************
** Reconcile a previous delivery when supported.
*******************************************************************************/
default Optional<DeliveryResult> lookup(String idempotencyKey) throws QException
{
return (Optional.empty());
}
}
@@ -0,0 +1,453 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
/*******************************************************************************
** DeliveryRequest.
*******************************************************************************/
public class DeliveryRequest implements java.io.Serializable
{
private String destinationName;
private String ownerUserId;
private String sourceType;
private String sourceKey;
private String title;
private String summary;
private String link;
private String idempotencyKey;
private Map<String, Serializable> targetValues = Map.of();
private List<DeliveryAttachment> attachments = List.of();
private DataClassification contentClassification;
private List<String> restrictedFieldLabels = List.of();
private boolean leaveOutRestricted;
/*******************************************************************************
** Get destinationName.
*******************************************************************************/
public String getDestinationName()
{
return (destinationName);
}
/*******************************************************************************
** Set destinationName.
*******************************************************************************/
public void setDestinationName(String destinationName)
{
this.destinationName = destinationName;
}
/*******************************************************************************
** With destinationName.
*******************************************************************************/
public DeliveryRequest withDestinationName(String destinationName)
{
this.destinationName = destinationName;
return (this);
}
/*******************************************************************************
** Get ownerUserId.
*******************************************************************************/
public String getOwnerUserId()
{
return (ownerUserId);
}
/*******************************************************************************
** Set ownerUserId.
*******************************************************************************/
public void setOwnerUserId(String ownerUserId)
{
this.ownerUserId = ownerUserId;
}
/*******************************************************************************
** With ownerUserId.
*******************************************************************************/
public DeliveryRequest withOwnerUserId(String ownerUserId)
{
this.ownerUserId = ownerUserId;
return (this);
}
/*******************************************************************************
** Get sourceType.
*******************************************************************************/
public String getSourceType()
{
return (sourceType);
}
/*******************************************************************************
** Set sourceType.
*******************************************************************************/
public void setSourceType(String sourceType)
{
this.sourceType = sourceType;
}
/*******************************************************************************
** With sourceType.
*******************************************************************************/
public DeliveryRequest withSourceType(String sourceType)
{
this.sourceType = sourceType;
return (this);
}
/*******************************************************************************
** Get sourceKey.
*******************************************************************************/
public String getSourceKey()
{
return (sourceKey);
}
/*******************************************************************************
** Set sourceKey.
*******************************************************************************/
public void setSourceKey(String sourceKey)
{
this.sourceKey = sourceKey;
}
/*******************************************************************************
** With sourceKey.
*******************************************************************************/
public DeliveryRequest withSourceKey(String sourceKey)
{
this.sourceKey = sourceKey;
return (this);
}
/*******************************************************************************
** Get title.
*******************************************************************************/
public String getTitle()
{
return (title);
}
/*******************************************************************************
** Set title.
*******************************************************************************/
public void setTitle(String title)
{
this.title = title;
}
/*******************************************************************************
** With title.
*******************************************************************************/
public DeliveryRequest withTitle(String title)
{
this.title = title;
return (this);
}
/*******************************************************************************
** Get summary.
*******************************************************************************/
public String getSummary()
{
return (summary);
}
/*******************************************************************************
** Set summary.
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** With summary.
*******************************************************************************/
public DeliveryRequest withSummary(String summary)
{
this.summary = summary;
return (this);
}
/*******************************************************************************
** Get link.
*******************************************************************************/
public String getLink()
{
return (link);
}
/*******************************************************************************
** Set link.
*******************************************************************************/
public void setLink(String link)
{
this.link = link;
}
/*******************************************************************************
** With link.
*******************************************************************************/
public DeliveryRequest withLink(String link)
{
this.link = link;
return (this);
}
/*******************************************************************************
** Get idempotencyKey.
*******************************************************************************/
public String getIdempotencyKey()
{
return (idempotencyKey);
}
/*******************************************************************************
** Set idempotencyKey.
*******************************************************************************/
public void setIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
}
/*******************************************************************************
** With idempotencyKey.
*******************************************************************************/
public DeliveryRequest withIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
return (this);
}
/*******************************************************************************
** Get targetValues.
*******************************************************************************/
public Map<String, Serializable> getTargetValues()
{
return (targetValues);
}
/*******************************************************************************
** Set targetValues.
*******************************************************************************/
public void setTargetValues(Map<String, Serializable> targetValues)
{
this.targetValues = targetValues;
}
/*******************************************************************************
** With targetValues.
*******************************************************************************/
public DeliveryRequest withTargetValues(Map<String, Serializable> targetValues)
{
this.targetValues = targetValues;
return (this);
}
/*******************************************************************************
** Get attachments.
*******************************************************************************/
public List<DeliveryAttachment> getAttachments()
{
return (attachments);
}
/*******************************************************************************
** Set attachments.
*******************************************************************************/
public void setAttachments(List<DeliveryAttachment> attachments)
{
this.attachments = attachments;
}
/*******************************************************************************
** With attachments.
*******************************************************************************/
public DeliveryRequest withAttachments(List<DeliveryAttachment> attachments)
{
this.attachments = attachments;
return (this);
}
/*******************************************************************************
** Get contentClassification.
*******************************************************************************/
public DataClassification getContentClassification()
{
return (contentClassification);
}
/*******************************************************************************
** Set contentClassification.
*******************************************************************************/
public void setContentClassification(DataClassification contentClassification)
{
this.contentClassification = contentClassification;
}
/*******************************************************************************
** With contentClassification.
*******************************************************************************/
public DeliveryRequest withContentClassification(DataClassification contentClassification)
{
this.contentClassification = contentClassification;
return (this);
}
/*******************************************************************************
** Get restrictedFieldLabels.
*******************************************************************************/
public List<String> getRestrictedFieldLabels()
{
return (restrictedFieldLabels);
}
/*******************************************************************************
** Set restrictedFieldLabels.
*******************************************************************************/
public void setRestrictedFieldLabels(List<String> restrictedFieldLabels)
{
this.restrictedFieldLabels = restrictedFieldLabels;
}
/*******************************************************************************
** With restrictedFieldLabels.
*******************************************************************************/
public DeliveryRequest withRestrictedFieldLabels(List<String> restrictedFieldLabels)
{
this.restrictedFieldLabels = restrictedFieldLabels;
return (this);
}
/*******************************************************************************
** Get leaveOutRestricted.
*******************************************************************************/
public boolean getLeaveOutRestricted()
{
return (leaveOutRestricted);
}
/*******************************************************************************
** Set leaveOutRestricted.
*******************************************************************************/
public void setLeaveOutRestricted(boolean leaveOutRestricted)
{
this.leaveOutRestricted = leaveOutRestricted;
}
/*******************************************************************************
** With leaveOutRestricted.
*******************************************************************************/
public DeliveryRequest withLeaveOutRestricted(boolean leaveOutRestricted)
{
this.leaveOutRestricted = leaveOutRestricted;
return (this);
}
}
@@ -0,0 +1,139 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
import java.util.List;
/*******************************************************************************
** DeliveryResult.
*******************************************************************************/
public class DeliveryResult implements java.io.Serializable
{
/*******************************************************************************
** Recorded delivery outcome.
*******************************************************************************/
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
public enum Status
{
DELIVERED, QUEUED, REFUSED, FAILED_RETRYABLE, FAILED, UNAUTHORIZED
}
private Status status;
private String externalReference;
private List<String> reasons = List.of();
/*******************************************************************************
** Get status.
*******************************************************************************/
public Status getStatus()
{
return (status);
}
/*******************************************************************************
** Set status.
*******************************************************************************/
public void setStatus(Status status)
{
this.status = status;
}
/*******************************************************************************
** With status.
*******************************************************************************/
public DeliveryResult withStatus(Status status)
{
this.status = status;
return (this);
}
/*******************************************************************************
** Get externalReference.
*******************************************************************************/
public String getExternalReference()
{
return (externalReference);
}
/*******************************************************************************
** Set externalReference.
*******************************************************************************/
public void setExternalReference(String externalReference)
{
this.externalReference = externalReference;
}
/*******************************************************************************
** With externalReference.
*******************************************************************************/
public DeliveryResult withExternalReference(String externalReference)
{
this.externalReference = externalReference;
return (this);
}
/*******************************************************************************
** Get reasons.
*******************************************************************************/
public List<String> getReasons()
{
return (reasons);
}
/*******************************************************************************
** Set reasons.
*******************************************************************************/
public void setReasons(List<String> reasons)
{
this.reasons = reasons;
}
/*******************************************************************************
** With reasons.
*******************************************************************************/
public DeliveryResult withReasons(List<String> reasons)
{
this.reasons = reasons;
return (this);
}
}
@@ -0,0 +1,129 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
/*******************************************************************************
** DeliveryTargetOption.
*******************************************************************************/
public class DeliveryTargetOption implements java.io.Serializable
{
private String value;
private String label;
private String description;
/*******************************************************************************
** Get value.
*******************************************************************************/
public String getValue()
{
return (value);
}
/*******************************************************************************
** Set value.
*******************************************************************************/
public void setValue(String value)
{
this.value = value;
}
/*******************************************************************************
** With value.
*******************************************************************************/
public DeliveryTargetOption withValue(String value)
{
this.value = value;
return (this);
}
/*******************************************************************************
** Get label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Set label.
*******************************************************************************/
public void setLabel(String label)
{
this.label = label;
}
/*******************************************************************************
** With label.
*******************************************************************************/
public DeliveryTargetOption withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Get description.
*******************************************************************************/
public String getDescription()
{
return (description);
}
/*******************************************************************************
** Set description.
*******************************************************************************/
public void setDescription(String description)
{
this.description = description;
}
/*******************************************************************************
** With description.
*******************************************************************************/
public DeliveryTargetOption withDescription(String description)
{
this.description = description;
return (this);
}
}
@@ -0,0 +1,226 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
import java.util.List;
/*******************************************************************************
** DeliveryTargetStep.
*******************************************************************************/
public class DeliveryTargetStep implements java.io.Serializable
{
private String name;
private String label;
private List<DeliveryTargetOption> options = List.of();
private boolean freeText;
private boolean complete;
private String summary;
/*******************************************************************************
** Get name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Set name.
*******************************************************************************/
public void setName(String name)
{
this.name = name;
}
/*******************************************************************************
** With name.
*******************************************************************************/
public DeliveryTargetStep withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Get label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Set label.
*******************************************************************************/
public void setLabel(String label)
{
this.label = label;
}
/*******************************************************************************
** With label.
*******************************************************************************/
public DeliveryTargetStep withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Get options.
*******************************************************************************/
public List<DeliveryTargetOption> getOptions()
{
return (options);
}
/*******************************************************************************
** Set options.
*******************************************************************************/
public void setOptions(List<DeliveryTargetOption> options)
{
this.options = options;
}
/*******************************************************************************
** With options.
*******************************************************************************/
public DeliveryTargetStep withOptions(List<DeliveryTargetOption> options)
{
this.options = options;
return (this);
}
/*******************************************************************************
** Get freeText.
*******************************************************************************/
public boolean getFreeText()
{
return (freeText);
}
/*******************************************************************************
** Set freeText.
*******************************************************************************/
public void setFreeText(boolean freeText)
{
this.freeText = freeText;
}
/*******************************************************************************
** With freeText.
*******************************************************************************/
public DeliveryTargetStep withFreeText(boolean freeText)
{
this.freeText = freeText;
return (this);
}
/*******************************************************************************
** Get complete.
*******************************************************************************/
public boolean getComplete()
{
return (complete);
}
/*******************************************************************************
** Set complete.
*******************************************************************************/
public void setComplete(boolean complete)
{
this.complete = complete;
}
/*******************************************************************************
** With complete.
*******************************************************************************/
public DeliveryTargetStep withComplete(boolean complete)
{
this.complete = complete;
return (this);
}
/*******************************************************************************
** Get summary.
*******************************************************************************/
public String getSummary()
{
return (summary);
}
/*******************************************************************************
** Set summary.
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** With summary.
*******************************************************************************/
public DeliveryTargetStep withSummary(String summary)
{
this.summary = summary;
return (this);
}
}
@@ -0,0 +1,396 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.delivery.standin; // STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
/*******************************************************************************
** QDeliveryDestinationMetaData.
*******************************************************************************/
public class QDeliveryDestinationMetaData implements java.io.Serializable
{
/*******************************************************************************
** Policy for content above the destination ceiling.
*******************************************************************************/
// STAND-IN(core=QRun-IO/qqq#1205, removeBy=4.1.0-RC.8)
public enum AboveMaxClassification
{
REFUSE, LINK_ONLY
}
private String name;
private String label;
private String icon;
private String permissionName;
private String emailProviderName;
private Integer order = 0;
private QCodeReference codeReference;
private DataClassification maxClassification = DataClassification.PUBLIC;
private Long maxAttachmentBytes;
private boolean allowBearerLinks;
private AboveMaxClassification aboveMaxClassification = AboveMaxClassification.REFUSE;
/*******************************************************************************
** Get name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Set name.
*******************************************************************************/
public void setName(String name)
{
this.name = name;
}
/*******************************************************************************
** With name.
*******************************************************************************/
public QDeliveryDestinationMetaData withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Get label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Set label.
*******************************************************************************/
public void setLabel(String label)
{
this.label = label;
}
/*******************************************************************************
** With label.
*******************************************************************************/
public QDeliveryDestinationMetaData withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Get icon.
*******************************************************************************/
public String getIcon()
{
return (icon);
}
/*******************************************************************************
** Set icon.
*******************************************************************************/
public void setIcon(String icon)
{
this.icon = icon;
}
/*******************************************************************************
** With icon.
*******************************************************************************/
public QDeliveryDestinationMetaData withIcon(String icon)
{
this.icon = icon;
return (this);
}
/*******************************************************************************
** Get permissionName.
*******************************************************************************/
public String getPermissionName()
{
return (permissionName);
}
/*******************************************************************************
** Set permissionName.
*******************************************************************************/
public void setPermissionName(String permissionName)
{
this.permissionName = permissionName;
}
/*******************************************************************************
** With permissionName.
*******************************************************************************/
public QDeliveryDestinationMetaData withPermissionName(String permissionName)
{
this.permissionName = permissionName;
return (this);
}
/*******************************************************************************
** Get emailProviderName.
*******************************************************************************/
public String getEmailProviderName()
{
return (emailProviderName);
}
/*******************************************************************************
** Set emailProviderName.
*******************************************************************************/
public void setEmailProviderName(String emailProviderName)
{
this.emailProviderName = emailProviderName;
}
/*******************************************************************************
** With emailProviderName.
*******************************************************************************/
public QDeliveryDestinationMetaData withEmailProviderName(String emailProviderName)
{
this.emailProviderName = emailProviderName;
return (this);
}
/*******************************************************************************
** Get order.
*******************************************************************************/
public Integer getOrder()
{
return (order);
}
/*******************************************************************************
** Set order.
*******************************************************************************/
public void setOrder(Integer order)
{
this.order = order;
}
/*******************************************************************************
** With order.
*******************************************************************************/
public QDeliveryDestinationMetaData withOrder(Integer order)
{
this.order = order;
return (this);
}
/*******************************************************************************
** Get codeReference.
*******************************************************************************/
public QCodeReference getCodeReference()
{
return (codeReference);
}
/*******************************************************************************
** Set codeReference.
*******************************************************************************/
public void setCodeReference(QCodeReference codeReference)
{
this.codeReference = codeReference;
}
/*******************************************************************************
** With codeReference.
*******************************************************************************/
public QDeliveryDestinationMetaData withCodeReference(QCodeReference codeReference)
{
this.codeReference = codeReference;
return (this);
}
/*******************************************************************************
** Get maxClassification.
*******************************************************************************/
public DataClassification getMaxClassification()
{
return (maxClassification);
}
/*******************************************************************************
** Set maxClassification.
*******************************************************************************/
public void setMaxClassification(DataClassification maxClassification)
{
this.maxClassification = maxClassification;
}
/*******************************************************************************
** With maxClassification.
*******************************************************************************/
public QDeliveryDestinationMetaData withMaxClassification(DataClassification maxClassification)
{
this.maxClassification = maxClassification;
return (this);
}
/*******************************************************************************
** Get maxAttachmentBytes.
*******************************************************************************/
public Long getMaxAttachmentBytes()
{
return (maxAttachmentBytes);
}
/*******************************************************************************
** Set maxAttachmentBytes.
*******************************************************************************/
public void setMaxAttachmentBytes(Long maxAttachmentBytes)
{
this.maxAttachmentBytes = maxAttachmentBytes;
}
/*******************************************************************************
** With maxAttachmentBytes.
*******************************************************************************/
public QDeliveryDestinationMetaData withMaxAttachmentBytes(Long maxAttachmentBytes)
{
this.maxAttachmentBytes = maxAttachmentBytes;
return (this);
}
/*******************************************************************************
** Get allowBearerLinks.
*******************************************************************************/
public boolean getAllowBearerLinks()
{
return (allowBearerLinks);
}
/*******************************************************************************
** Set allowBearerLinks.
*******************************************************************************/
public void setAllowBearerLinks(boolean allowBearerLinks)
{
this.allowBearerLinks = allowBearerLinks;
}
/*******************************************************************************
** With allowBearerLinks.
*******************************************************************************/
public QDeliveryDestinationMetaData withAllowBearerLinks(boolean allowBearerLinks)
{
this.allowBearerLinks = allowBearerLinks;
return (this);
}
/*******************************************************************************
** Get aboveMaxClassification.
*******************************************************************************/
public AboveMaxClassification getAboveMaxClassification()
{
return (aboveMaxClassification);
}
/*******************************************************************************
** Set aboveMaxClassification.
*******************************************************************************/
public void setAboveMaxClassification(AboveMaxClassification aboveMaxClassification)
{
this.aboveMaxClassification = aboveMaxClassification;
}
/*******************************************************************************
** With aboveMaxClassification.
*******************************************************************************/
public QDeliveryDestinationMetaData withAboveMaxClassification(AboveMaxClassification aboveMaxClassification)
{
this.aboveMaxClassification = aboveMaxClassification;
return (this);
}
}
@@ -0,0 +1,132 @@
/*
* 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.interactivity;
import java.util.List;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.api.SlackActionMetaData;
import com.kingsrook.qbits.slack.outbox.SlackSendRequest;
import com.kingsrook.qbits.slack.phi.SlackPayloadGuard;
import com.kingsrook.qbits.slack.security.SlackActionToken;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Server-rendered action blocks, signed only after the message has its row id.
*******************************************************************************/
public final class SlackActions
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackActions()
{
}
/*******************************************************************************
** Optional alert integration by process name, without an alerts dependency.
*******************************************************************************/
public static void registerBuiltIns(QInstance instance, SlackQBitConfig config)
{
for(String name : List.of("acknowledge_alert", "resolve_alert"))
{
if(instance.getProcess(name) != null && !config.getActions().containsKey(name))
{
config.withAction(new SlackActionMetaData().withName(name).withLabel("acknowledge_alert".equals(name) ? "Acknowledge" : "Resolve")
.withProcessName(name).withPermissionName(name));
}
}
}
/*******************************************************************************
** Validate before inserting a message; action text obeys the payload guard.
*******************************************************************************/
public static void validate(SlackSendRequest request)
{
if(request.getActions().isEmpty())
{
return;
}
if(request.getActions().size() > 25 || request.getLinkedTableName() == null || request.getLinkedRecordId() == null
|| "SECURITY".equals(request.getCategory()) || !request.getAttachments().isEmpty())
{
throw (new IllegalArgumentException("Slack actions require a linked message"));
}
for(String name : request.getActions())
{
SlackActionMetaData action = SlackRuntime.config().getActions().get(name);
if(action == null)
{
throw (new IllegalArgumentException("Unknown Slack action"));
}
SlackPayloadGuard.text(action.getLabel(), null);
SlackPayloadGuard.text(action.getConfirmTitle(), null);
SlackPayloadGuard.text(action.getConfirmText(), null);
}
}
/*******************************************************************************
** Retain rendered template blocks and append signed buttons.
*******************************************************************************/
public static String blocks(QRecord row, SlackSendRequest request)
{
JSONArray blocks = row.getValueString("blocksJson") == null ? new JSONArray() : new JSONArray(row.getValueString("blocksJson"));
if(blocks.isEmpty())
{
blocks.put(new JSONObject().put("type", "section").put("text", new JSONObject().put("type", "mrkdwn").put("text", row.getValueString("text"))));
}
JSONArray buttons = new JSONArray();
for(String name : request.getActions())
{
SlackActionMetaData action = SlackRuntime.config().getActions().get(name);
JSONObject button = new JSONObject().put("type", "button").put("action_id", name)
.put("text", plain(action.getLabel())).put("value", SlackActionToken.issue(SlackRuntime.config(), row.getValueInteger("id"), name,
request.getLinkedTableName(), request.getLinkedRecordId()));
if(action.getStyle() != null)
{
button.put("style", action.getStyle());
}
if(action.getConfirmTitle() != null)
{
button.put("confirm", new JSONObject().put("title", plain(action.getConfirmTitle())).put("text", plain(action.getConfirmText()))
.put("confirm", plain("Confirm")).put("deny", plain("Cancel")));
}
buttons.put(button);
}
blocks.put(new JSONObject().put("type", "actions").put("elements", buttons));
if(blocks.length() > 50 || blocks.toString().length() >= 40000)
{
throw (new IllegalArgumentException("Slack actions exceed message limits"));
}
return (blocks.toString());
}
/*******************************************************************************
** Literal text never introduces Slack markup.
*******************************************************************************/
private static JSONObject plain(String value)
{
return (new JSONObject().put("type", "plain_text").put("text", value));
}
}
@@ -0,0 +1,155 @@
/*
* 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.interactivity;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qbits.slack.security.SlackRequestVerifier;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import io.javalin.config.JavalinConfig;
import io.javalin.http.Context;
import org.json.JSONObject;
/*******************************************************************************
** Signature-gated Slack callbacks. Bounded admission returns 503 when saturated
** so Slack retries instead of silently dropping a click. Worker context is fresh.
*******************************************************************************/
public class SlackCallbackRouteProvider implements QJavalinRouteProviderInterface
{
private static final Executor WORKERS = new ThreadPoolExecutor(2, 2, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), task ->
{
Thread thread = new Thread(task, "slack-callback");
thread.setDaemon(true);
return (thread);
}, new ThreadPoolExecutor.AbortPolicy());
private Executor executor = WORKERS;
private QInstance instance;
/*******************************************************************************
** Synchronous execution hook for HTTP integration tests.
*******************************************************************************/
public SlackCallbackRouteProvider withExecutor(Executor value)
{
executor = java.util.Objects.requireNonNull(value);
return (this);
}
/*******************************************************************************
** Receive the host instance.
*******************************************************************************/
@Override
public void setQInstance(QInstance value)
{
instance = value;
}
/*******************************************************************************
** Slack callbacks are exempt from UI authentication, not signature checks.
*******************************************************************************/
@Override
public void acceptJavalinConfig(JavalinConfig config)
{
config.routes.post("/qqq/hooks/slack/interactions", context -> callback(context, false));
config.routes.post("/qqq/hooks/slack/events", context -> callback(context, true));
}
/*******************************************************************************
** Verify the exact bytes before form or JSON decoding. No body is echoed on
** errors, including malformed JSON and missing secrets.
*******************************************************************************/
private void callback(Context context, boolean events)
{
CapturedContext previous = QContext.capture();
context.header("Cache-Control", "no-store");
try
{
QContext.init(instance, new QSystemUserSession(instance));
if(!SlackRequestVerifier.verify(SlackRuntime.config(instance), context.header("X-Slack-Request-Timestamp"),
context.header("X-Slack-Signature"), context.bodyAsBytes()))
{
context.status(401).result("");
SlackInteractions.rejected();
return;
}
String payload = events ? context.body() : context.formParam("payload");
if(events && "url_verification".equals(new JSONObject(payload).optString("type")))
{
context.json(java.util.Map.of("challenge", new JSONObject(payload).getString("challenge")));
return;
}
executor.execute(() -> work(payload, events));
context.status(200).result("");
}
catch(RejectedExecutionException e)
{
context.status(503).result("");
}
catch(Exception e)
{
if(context.status() != io.javalin.http.HttpStatus.UNAUTHORIZED)
{
context.status(400).result("");
}
}
finally
{
QContext.init(previous);
}
}
/*******************************************************************************
** No request session or transaction leaks onto pooled threads. Processing
** errors carry fixed diagnostics; interaction failures are recorded by handler.
*******************************************************************************/
private void work(String payload, boolean events)
{
CapturedContext previous = QContext.capture();
try
{
QContext.init(instance, new QSystemUserSession(instance));
if(events)
{
SlackEvents.handle(new JSONObject(payload));
}
else
{
SlackInteractions.handle(payload);
}
}
catch(Exception e)
{
com.kingsrook.qqq.backend.core.logging.QLogger.getLogger(SlackCallbackRouteProvider.class).warn("Slack callback processing failed");
}
finally
{
QContext.init(previous);
}
}
}
@@ -0,0 +1,83 @@
/*
* 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.interactivity;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Slack-authoritative revocations intentionally affect every tenant install of
** the team. No tenant session is used to narrow an authenticated Slack event.
*******************************************************************************/
public final class SlackEvents
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackEvents()
{
}
/*******************************************************************************
** Idempotently clear revoked credentials across all matching workspace rows.
*******************************************************************************/
public static void handle(JSONObject payload) throws QException
{
if(!"event_callback".equals(payload.optString("type")))
{
return;
}
JSONObject event = payload.getJSONObject("event");
String type = event.optString("type");
if(!java.util.List.of("tokens_revoked", "app_uninstalled").contains(type))
{
return;
}
JSONObject tokens = event.optJSONObject("tokens");
JSONArray users = tokens == null ? new JSONArray() : tokens.optJSONArray("oauth");
JSONArray bots = tokens == null ? new JSONArray() : tokens.optJSONArray("bot");
boolean uninstall = "app_uninstalled".equals(type);
for(QRecord workspace : SlackStore.query(SlackTables.WORKSPACE, SlackStore.equal("teamId", payload.getString("team_id"))))
{
int id = workspace.getValueInteger("id");
if(uninstall || bots != null && !bots.isEmpty())
{
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("status", uninstall ? "UNINSTALLED" : "REVOKED")
.withValue("botToken", null).withValue("botTokenFingerprint", null), null);
}
for(QRecord connection : SlackStore.query(SlackTables.CONNECTION, SlackStore.equal("workspaceId", id)))
{
if(uninstall || users != null && users.toList().contains(connection.getValueString("slackUserId")))
{
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", connection.getValueInteger("id"))
.withValue("status", "REVOKED").withValue("userToken", null).withValue("userTokenFingerprint", null)
.withValue("revokedAt", SlackRuntime.clock().instant()), null);
}
}
}
SlackRuntime.membership().clear();
}
}
@@ -0,0 +1,252 @@
/*
* 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.interactivity;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.slack.api.SlackActionMetaData;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.outbox.SlackSendResult;
import com.kingsrook.qbits.slack.outbox.SlackService;
import com.kingsrook.qbits.slack.security.SlackActionToken;
import com.kingsrook.qbits.slack.security.SlackOwnerSession;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONObject;
/*******************************************************************************
** Authenticate message binding, claim retries durably, and run as the clicker.
** No callback payload, response URL or button token is retained in the audit.
*******************************************************************************/
public final class SlackInteractions
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackInteractions()
{
}
/*******************************************************************************
** An untrusted request gets a payload-free rejection row.
*******************************************************************************/
public static void rejected() throws QException
{
SlackStore.insert(SlackTables.INTERACTION, new QRecord().withValue("outcome", "REJECTED")
.withValue("completedDate", SlackRuntime.clock().instant()), null);
}
/*******************************************************************************
** The database unique delivery key is the cross-node replay barrier. Once a
** process starts, retries never run it again, including after an uncertain crash.
*******************************************************************************/
public static void handle(String rawPayload) throws QException
{
String key;
try
{
key = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(rawPayload.getBytes(StandardCharsets.UTF_8)));
}
catch(Exception e)
{
throw (new QException("Could not identify Slack interaction"));
}
if(!SlackStore.query(SlackTables.INTERACTION, SlackStore.equal("deliveryKey", key)).isEmpty())
{
return;
}
QRecord audit;
try
{
audit = SlackStore.insert(SlackTables.INTERACTION, new QRecord().withValue("deliveryKey", key), null);
if(audit.getValueInteger("id") == null || audit.getErrors() != null && !audit.getErrors().isEmpty())
{
return;
}
}
catch(QException e)
{
if(!SlackStore.query(SlackTables.INTERACTION, SlackStore.equal("deliveryKey", key)).isEmpty())
{
return;
}
throw (new QException("Could not record Slack interaction"));
}
try
{
execute(new JSONObject(rawPayload), audit);
}
catch(Exception e)
{
finish(audit, "REJECTED", "Invalid interaction");
}
}
/*******************************************************************************
** Only authenticated token claims can bind an audit to an existing message.
*******************************************************************************/
private static void execute(JSONObject payload, QRecord audit) throws QException
{
if(!"block_actions".equals(payload.optString("type")) || payload.getJSONArray("actions").length() != 1)
{
finish(audit, "REJECTED", "Unsupported interaction");
return;
}
JSONObject button = payload.getJSONArray("actions").getJSONObject(0);
SlackActionToken.Claims claims = SlackActionToken.verify(SlackRuntime.config(), button.getString("value"));
if(claims.expiry() <= SlackRuntime.clock().instant().getEpochSecond())
{
finish(audit, "EXPIRED", null);
return;
}
QRecord message = SlackStore.get(SlackTables.MESSAGE, claims.messageId());
JSONObject container = payload.getJSONObject("container");
if(message == null || !"SENT".equals(message.getValueString("status"))
|| !Objects.equals(claims.actionName(), button.getString("action_id"))
|| !Objects.equals(claims.tableName(), message.getValueString("linkedTableName"))
|| !Objects.equals(claims.recordId(), message.getValueString("linkedRecordId"))
|| !Objects.equals(container.getString("message_ts"), message.getValueString("slackTs"))
|| !Objects.equals(container.getString("channel_id"), message.getValueString("slackChannelId")))
{
finish(audit, "REJECTED", "Invalid message binding");
return;
}
int workspaceId = message.getValueInteger("workspaceId");
QRecord workspace = SlackStore.get(SlackTables.WORKSPACE, workspaceId);
String team = payload.getJSONObject("team").getString("id");
String slackUser = payload.getJSONObject("user").getString("id");
if(workspace == null || !"ACTIVE".equals(workspace.getValueString("status")) || !team.equals(workspace.getValueString("teamId")))
{
finish(audit, "REJECTED", "Invalid workspace binding");
return;
}
audit.withValue("workspaceId", workspaceId).withValue("messageId", claims.messageId()).withValue("teamId", team)
.withValue("slackUserId", slackUser).withValue("actionName", claims.actionName());
List<QRecord> connections = SlackStore.query(SlackTables.CONNECTION, SlackStore.equal("workspaceId", workspaceId)).stream()
.filter(row -> "ACTIVE".equals(row.getValueString("status")) && slackUser.equals(row.getValueString("slackUserId"))).toList();
if(connections.size() != 1)
{
finish(audit, "UNLINKED", null);
respond(payload, "Connect Slack in Preferences to use this button");
return;
}
audit.withValue("userId", connections.getFirst().getValueString("userId"));
SlackActionMetaData action = SlackRuntime.config().getActions().get(claims.actionName());
if(action == null)
{
finish(audit, "REJECTED", "Unknown action");
return;
}
String completion;
try
{
completion = SlackOwnerSession.call(audit.getValueString("userId"), () -> run(action, claims, workspaceId));
}
catch(QPermissionDeniedException e)
{
finish(audit, "DENIED", "Permission denied");
respond(payload, "You don't have permission to " + action.getLabel());
return;
}
catch(Exception e)
{
finish(audit, "FAILED", "Process could not complete");
respond(payload, action.getLabel() + " failed: Process could not complete");
return;
}
finish(audit, "DONE", null);
try
{
SlackSendResult result = SlackService.updateInteraction(message, audit.getValueInteger("id"), completion);
if(result.status() == SlackSendResult.Status.REFUSED)
{
finish(audit, "DONE", "Message update unavailable");
}
}
catch(Exception e)
{
finish(audit, "DONE", "Message update unavailable");
}
}
/*******************************************************************************
** Explicit record lookup enforces locks even for processes without an extract
** step; the normal process callback re-fetches by primary key as this user.
*******************************************************************************/
private static String run(SlackActionMetaData action, SlackActionToken.Claims claims, int workspaceId) throws QException
{
QSession session = QContext.getQSession();
var process = QContext.getQInstance().getProcess(action.getProcessName());
var table = QContext.getQInstance().getTable(claims.tableName());
if(!session.hasPermission(action.getPermissionName()) || process == null || table == null
|| !Objects.equals(process.getTableName(), claims.tableName())
|| SlackStore.queryAsCurrent(SlackTables.WORKSPACE, SlackStore.equal("id", workspaceId)).isEmpty()
|| SlackStore.queryAsCurrent(claims.tableName(), SlackStore.equal(table.getPrimaryKeyField(), claims.recordId())).size() != 1)
{
throw (new QPermissionDeniedException("Permission denied"));
}
var result = new RunProcessAction().execute(new RunProcessInput().withProcessName(action.getProcessName())
.withCallback(QProcessCallbackFactory.forPrimaryKey(table.getPrimaryKeyField(), claims.recordId()))
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
if(result.getException().isPresent())
{
throw (new QException("Process could not complete"));
}
String label = Objects.toString(session.getUser().getFullName(), session.getUser().getIdReference());
ZoneId zone = SlackRuntime.clock().getZone();
Object configuredZone = session.getValue(QSession.VALUE_KEY_USER_TIMEZONE);
if(configuredZone instanceof String zoneName)
{
zone = ZoneId.of(zoneName);
}
return (action.getLabel() + " by " + label + " at " + DateTimeFormatter.ofPattern("HH:mm").withZone(zone).format(SlackRuntime.clock().instant()));
}
/*******************************************************************************
** Only fixed, payload-free diagnostic reasons reach storage.
*******************************************************************************/
private static void finish(QRecord audit, String outcome, String error) throws QException
{
audit.withValue("outcome", outcome).withValue("error", error).withValue("completedDate", SlackRuntime.clock().instant());
SlackStore.update(SlackTables.INTERACTION, audit, null);
}
/*******************************************************************************
** Response URL validation lives in the transport, and never forwards a token.
*******************************************************************************/
private static void respond(JSONObject payload, String text)
{
SlackRuntime.client(SlackRuntime.config()).respondEphemeral(payload.optString("response_url"), text);
}
}
@@ -0,0 +1,206 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.metadata;
import java.util.Locale;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
/*******************************************************************************
** Enumerated database values and their display labels (docs/DESIGN.md
** sections 3, 5 and 8). Each is registered as a possible-value source by
** SlackTables.
*******************************************************************************/
public final class SlackEnums
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackEnums()
{
}
/*******************************************************************************
** Shared id and label rules: the stored value is the constant name, and the
** label is its plain English form.
*******************************************************************************/
public interface SlackEnum extends PossibleValueEnum<String>
{
/*******************************************************************************
** The constant name (satisfied by every enum).
*******************************************************************************/
String name();
/*******************************************************************************
** Stable database value.
*******************************************************************************/
@Override
default String getPossibleValueId()
{
return (name());
}
/*******************************************************************************
** Plain display label.
*******************************************************************************/
@Override
default String getPossibleValueLabel()
{
return (name().charAt(0) + name().substring(1).toLowerCase(Locale.ROOT).replace('_', ' '));
}
}
/*******************************************************************************
** Workspace status.
*******************************************************************************/
public enum WorkspaceStatus implements SlackEnum
{
ACTIVE,
REVOKED,
UNINSTALLED,
ERROR
}
/*******************************************************************************
** Connection status.
*******************************************************************************/
public enum ConnectionStatus implements SlackEnum
{
ACTIVE,
REVOKED,
ERROR
}
/*******************************************************************************
** Readiness check result (section 8).
*******************************************************************************/
public enum ReadinessStatus implements SlackEnum
{
UNKNOWN,
OK,
WARN,
FAIL
}
/*******************************************************************************
** Workspace delivery health (section 5.3).
*******************************************************************************/
public enum HealthStatus implements SlackEnum
{
HEALTHY,
UNHEALTHY
}
/*******************************************************************************
** Outbox message status (section 5.1).
*******************************************************************************/
public enum MessageStatus implements SlackEnum
{
QUEUED,
WAITING_PARENT,
SENDING,
RETRY_WAIT,
SENT,
DEAD_LETTER,
FAILED_AUTH,
REFUSED,
SUPPRESSED,
CANCELLED
}
/*******************************************************************************
** What the dispatcher does with a message (section 5.1).
*******************************************************************************/
public enum Operation implements SlackEnum
{
POST,
UPDATE,
FILE
}
/*******************************************************************************
** Kind of target a message is addressed to (section 5.1).
*******************************************************************************/
public enum TargetType implements SlackEnum
{
ME,
USER,
CHANNEL,
REGISTERED_CHANNEL,
USER_GROUP
}
/*******************************************************************************
** Posting identity (section 4.1).
*******************************************************************************/
public enum PostAs implements SlackEnum
{
BOT,
USER
}
/*******************************************************************************
** Result of one delivery attempt (section 5.2 and 5.3).
*******************************************************************************/
public enum AttemptOutcome implements SlackEnum
{
CLAIMED,
SENT,
RATE_LIMITED,
TRANSIENT_FAILURE,
PERMANENT_FAILURE,
AUTH_FAILURE,
LEASE_EXPIRED
}
/*******************************************************************************
** Terminal interaction outcomes; an uncompleted row is in flight.
*******************************************************************************/
public enum InteractionOutcome implements SlackEnum
{
DONE, DENIED, FAILED, REJECTED, EXPIRED, UNLINKED
}
}
@@ -0,0 +1,604 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.metadata;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.security.SlackSecurity;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
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.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** The Slack qbit's tables (docs/DESIGN.md sections 3.1, 3.2, 5.1, 6.2, 6.3 and
** 14.1): workspaces, connections, registered channels, user groups, templates,
** the message outbox, attempts and interaction audits.
**
** Security, in one place:
** - The host's tenant lock (config securityFields and recordSecurityLocks) goes
** on slack_workspace, and on slack_template (where a null tenant means shared).
** Every other table inherits it from its workspace through a join lock, so no
** child can be read or written across tenants.
** - slack_connection is owner-locked on userId, and slack_message on
** ownerUserId (slack_user key type). Attempts inherit the message's owner and
** tenant through the message.
** - bot_token and user_token are PASSWORD fields that are hidden, sensitive and
** not editable, sit in no section, and carry a FieldSecurityLock nobody can
** open (see tokenField).
*******************************************************************************/
public final class SlackTables
{
public static final String WORKSPACE = "slack_workspace";
public static final String CONNECTION = "slack_connection";
public static final String CHANNEL = "slack_channel";
public static final String USER_GROUP = "slack_user_group";
public static final String TEMPLATE = "slack_template";
public static final String MESSAGE = "slack_message";
public static final String INTERACTION = "slack_interaction";
public static final String JOIN_MESSAGE_INTERACTION = MESSAGE + "_join_" + INTERACTION;
public static final String JOIN_WORKSPACE_INTERACTION = WORKSPACE + "_join_" + INTERACTION;
public static final String ATTEMPT = "slack_message_attempt";
public static final List<String> MAIN = List.of(MESSAGE, CONNECTION, WORKSPACE, CHANNEL, USER_GROUP, TEMPLATE);
public static final List<String> ALL = List.of(WORKSPACE, CONNECTION, CHANNEL, USER_GROUP, TEMPLATE, MESSAGE, ATTEMPT, INTERACTION);
/////////////////////////////////////////////////////////////////////////////
// written by the qbit itself (the dispatcher and the OAuth callback), never //
// by users through a table screen //
/////////////////////////////////////////////////////////////////////////////
public static final List<String> SYSTEM_WRITTEN = List.of(MESSAGE, ATTEMPT, INTERACTION);
public static final String JOIN_WORKSPACE_CONNECTION = WORKSPACE + "_join_" + CONNECTION;
public static final String JOIN_WORKSPACE_CHANNEL = WORKSPACE + "_join_" + CHANNEL;
public static final String JOIN_WORKSPACE_USER_GROUP = WORKSPACE + "_join_" + USER_GROUP;
public static final String JOIN_WORKSPACE_MESSAGE = WORKSPACE + "_join_" + MESSAGE;
public static final String JOIN_MESSAGE_ATTEMPT = MESSAGE + "_join_" + ATTEMPT;
public static final String JOIN_MESSAGE_PARENT_CHILD = MESSAGE + "_join_replies";
public static final String BOT_TOKEN = "botToken";
public static final String USER_TOKEN = "userToken";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackTables()
{
}
/*******************************************************************************
** Permission base shared by every table.
*******************************************************************************/
public static QPermissionRules permissionRules()
{
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("slack")
.withCustomPermissionChecker(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.slack.permissions.SlackPermissionChecker.class)));
}
/*******************************************************************************
** All enum and table possible-value sources.
*******************************************************************************/
public static List<QPossibleValueSource> possibleValueSources()
{
List<QPossibleValueSource> result = new ArrayList<>();
result.add(QPossibleValueSource.newForEnum("slack_workspace_status", SlackEnums.WorkspaceStatus.values()));
result.add(QPossibleValueSource.newForEnum("slack_connection_status", SlackEnums.ConnectionStatus.values()));
result.add(QPossibleValueSource.newForEnum("slack_readiness_status", SlackEnums.ReadinessStatus.values()));
result.add(QPossibleValueSource.newForEnum("slack_health_status", SlackEnums.HealthStatus.values()));
result.add(QPossibleValueSource.newForEnum("slack_message_status", SlackEnums.MessageStatus.values()));
result.add(QPossibleValueSource.newForEnum("slack_message_operation", SlackEnums.Operation.values()));
result.add(QPossibleValueSource.newForEnum("slack_target_type", SlackEnums.TargetType.values()));
result.add(QPossibleValueSource.newForEnum("slack_post_as", SlackEnums.PostAs.values()));
result.add(QPossibleValueSource.newForEnum("slack_interaction_outcome", SlackEnums.InteractionOutcome.values()));
result.add(QPossibleValueSource.newForEnum("slack_attempt_outcome", SlackEnums.AttemptOutcome.values()));
for(String name : ALL)
{
QPossibleValueSource source = QPossibleValueSource.newForTable(name);
switch(name)
{
case WORKSPACE -> source.withSearchFields(List.of("teamName", "teamId")).withOrderByField("id");
case CHANNEL, USER_GROUP, TEMPLATE -> source.withSearchFields(List.of("label", "name")).withOrderByField("id");
default -> source.withOrderByField("id");
}
result.add(source);
}
return (result);
}
/*******************************************************************************
** The tables, with the host's tenant lock and the owner locks applied.
*******************************************************************************/
public static List<QTableMetaData> tables(SlackQBitConfig config)
{
List<QTableMetaData> tables = new ArrayList<>();
tables.add(workspace(config));
tables.add(connection(config));
tables.add(channel(config));
tables.add(userGroup(config));
tables.add(template(config));
tables.add(message(config));
tables.add(attempt(config));
tables.add(interaction(config));
for(QTableMetaData table : tables)
{
switch(table.getName())
{
case WORKSPACE -> addTenantLocks(table, config, true, null);
case TEMPLATE -> addTenantLocks(table, config, false, null);
case CONNECTION -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_CONNECTION));
case CHANNEL -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_CHANNEL));
case USER_GROUP -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_USER_GROUP));
case INTERACTION -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_INTERACTION));
case MESSAGE -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_MESSAGE));
case ATTEMPT -> addTenantLocks(table, config, true, List.of(JOIN_WORKSPACE_MESSAGE, JOIN_MESSAGE_ATTEMPT));
default -> throw (new IllegalStateException("Unexpected table"));
}
if(SYSTEM_WRITTEN.contains(table.getName()))
{
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
table.getFields().values().forEach(f -> f.setIsEditable(false));
}
else if(List.of(WORKSPACE, CONNECTION).contains(table.getName()))
{
//////////////////////////////////////////////////////////////////
// created only by the OAuth callback, never from a table form //
//////////////////////////////////////////////////////////////////
table.withoutCapabilities(Capability.TABLE_INSERT);
}
table.getFields().values().forEach(f -> f.setBackendName(QInstanceEnricher.inferBackendName(f.getName())));
}
return (tables);
}
/*******************************************************************************
** Add the host's tenant fields and locks. A table that holds the tenant value
** itself (the workspace, and the template) gets the fields and the locks as
** configured; the template's null tenant means shared, so its locks allow
** nulls. Every other table gets the same locks reached through a join chain
** from the workspace.
*******************************************************************************/
private static void addTenantLocks(QTableMetaData table, SlackQBitConfig config, boolean denyNull, List<String> chainFromWorkspace)
{
if(chainFromWorkspace == null)
{
for(QFieldMetaData field : config.getSecurityFields())
{
if(!table.getFields().containsKey(field.getName()))
{
table.addField(field.clone());
table.getSections().get(0).getFieldNames().add(field.getName());
}
}
}
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
RecordSecurityLock clone = lock.clone();
if(chainFromWorkspace != null)
{
clone.setFieldName(WORKSPACE + "." + lock.getFieldName());
clone.setJoinNameChain(chainFromWorkspace);
}
if(!denyNull)
{
clone.setNullValueBehavior(RecordSecurityLock.NullValueBehavior.ALLOW);
}
table.withRecordSecurityLock(clone);
}
if(CONNECTION.equals(table.getName()) || INTERACTION.equals(table.getName()))
{
table.withRecordSecurityLock(SlackSecurity.ownerLock("userId"));
}
if(MESSAGE.equals(table.getName()))
{
table.withRecordSecurityLock(SlackSecurity.ownerLock("ownerUserId"));
}
if(ATTEMPT.equals(table.getName()))
{
table.withRecordSecurityLock(SlackSecurity.ownerLock(MESSAGE + ".ownerUserId", List.of(JOIN_MESSAGE_ATTEMPT)));
}
}
/*******************************************************************************
** Common fields.
*******************************************************************************/
private static QTableMetaData base(SlackQBitConfig config, String name, String label)
{
return (new QTableMetaData().withName(name).withLabel(label).withBackendName(config.getBackendName())
.withPrimaryKeyField("id").withIcon(new QIcon("chat")).withPermissionRules(permissionRules())
.withRecordLabelFormatAndFields("%s", "id")
.withField(field("id", QFieldType.INTEGER).withIsEditable(false))
.withField(field("createDate", QFieldType.DATE_TIME).withIsEditable(false))
.withField(field("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)));
}
/*******************************************************************************
** Field with a plain English label.
*******************************************************************************/
private static QFieldMetaData field(String name, QFieldType type)
{
String words = name.replaceAll("([a-z])([A-Z])", "$1 $2");
return (new QFieldMetaData(name, type).withLabel(Character.toUpperCase(words.charAt(0)) + words.substring(1)));
}
/*******************************************************************************
** A token column, hidden from every API and screen. RC.6 has no single "never
** returned" switch, so every layer is used:
** - PASSWORD type: masked in any query that masks passwords (the default);
** - isHidden: omitted from query and get results by default, and from forms;
** - FieldSecurityLock (default DENY, key nobody holds): when core enforces
** permissions (every UI and API call does), the value is removed from
** results, the field is removed from the table's metadata, and filtering,
** ordering or writing it is refused. The system user is exempt, which is
** how the dispatcher reads it, with shouldOmitHiddenFields and
** shouldMaskPasswords both false;
** - isSensitive: excluded from exports;
** - not editable, and in no section or record label;
** - the table has no audit rules, so the value is never audited.
*******************************************************************************/
private static QFieldMetaData tokenField(String name)
{
return (field(name, QFieldType.PASSWORD).withMaxLength(2000).withIsHidden(true).withIsSensitive(true).withIsEditable(false)
.withFieldSecurityLock(SlackSecurity.tokenFieldLock()));
}
/*******************************************************************************
** Metadata for slack_workspace.
*******************************************************************************/
private static QTableMetaData workspace(SlackQBitConfig config)
{
QTableMetaData table = base(config, WORKSPACE, "Workspaces")
.withField(field("teamId", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("teamName", QFieldType.STRING).withMaxLength(500))
.withField(field("enterpriseId", QFieldType.STRING).withMaxLength(500))
.withField(field("appId", QFieldType.STRING).withMaxLength(500))
.withField(field("tenantKey", QFieldType.STRING).withIsRequired(!config.getRecordSecurityLocks().isEmpty()).withMaxLength(500))
.withField(field("installedByUserId", QFieldType.STRING).withMaxLength(500))
.withField(field("installedAt", QFieldType.DATE_TIME))
.withField(field("botUserId", QFieldType.STRING).withMaxLength(500))
.withField(field("botScopes", QFieldType.TEXT))
.withField(tokenField(BOT_TOKEN))
.withField(field("botTokenFingerprint", QFieldType.STRING).withMaxLength(500))
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("slack_workspace_status").withDefaultValue("ACTIVE").withMaxLength(500))
.withField(field("readinessStatus", QFieldType.STRING).withPossibleValueSourceName("slack_readiness_status").withDefaultValue("UNKNOWN").withMaxLength(500))
.withField(field("readinessCheckedAt", QFieldType.DATE_TIME))
.withField(field("readinessSummary", QFieldType.STRING).withMaxLength(500))
.withField(field("health", QFieldType.STRING).withPossibleValueSourceName("slack_health_status").withDefaultValue("HEALTHY").withMaxLength(500))
.withField(field("consecutiveFailures", QFieldType.INTEGER).withDefaultValue(0))
.withField(field("ratePerMinute", QFieldType.INTEGER).withDefaultValue(50));
table.withRecordLabelFormatAndFields("%s", "teamName");
table.withUniqueKey(new UniqueKey("teamId", "tenantKey"));
table.withSection(SectionFactory.defaultT1("id", "teamId", "teamName", "tenantKey", "status"));
table.withSection(SectionFactory.defaultT2("enterpriseId", "appId", "installedByUserId", "installedAt", "botUserId", "botScopes", "botTokenFingerprint", "readinessStatus", "readinessCheckedAt", "readinessSummary", "health", "consecutiveFailures", "ratePerMinute"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
table.withSection(SectionFactory.customT2("connections", new QIcon("list")).withLabel("Connections").withWidgetName(childWidgetName(CONNECTION)));
table.withSection(SectionFactory.customT2("channels", new QIcon("list")).withLabel("Registered Channels").withWidgetName(childWidgetName(CHANNEL)));
table.withSection(SectionFactory.customT2("groups", new QIcon("list")).withLabel("User Groups").withWidgetName(childWidgetName(USER_GROUP)));
return (table);
}
/*******************************************************************************
** Metadata for slack_connection.
*******************************************************************************/
private static QTableMetaData connection(SlackQBitConfig config)
{
QTableMetaData table = base(config, CONNECTION, "Connections")
.withField(field("userId", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true))
.withField(field("slackUserId", QFieldType.STRING).withMaxLength(500))
.withField(field("slackUserName", QFieldType.STRING).withMaxLength(500))
.withField(tokenField(USER_TOKEN))
.withField(field("userTokenFingerprint", QFieldType.STRING).withMaxLength(500))
.withField(field("userScopes", QFieldType.TEXT))
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("slack_connection_status").withDefaultValue("ACTIVE").withMaxLength(500))
.withField(field("connectedAt", QFieldType.DATE_TIME))
.withField(field("lastVerifiedAt", QFieldType.DATE_TIME))
.withField(field("revokedAt", QFieldType.DATE_TIME))
.withField(field("lastError", QFieldType.STRING).withMaxLength(500));
table.withUniqueKey(new UniqueKey("userId", "workspaceId"));
table.withSection(SectionFactory.defaultT1("id", "userId", "workspaceId", "slackUserName", "status"));
table.withSection(SectionFactory.defaultT2("slackUserId", "userTokenFingerprint", "userScopes", "connectedAt", "lastVerifiedAt", "revokedAt", "lastError"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Metadata for slack_channel (registered channels).
*******************************************************************************/
private static QTableMetaData channel(SlackQBitConfig config)
{
QTableMetaData table = base(config, CHANNEL, "Registered Channels")
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.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).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).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"));
table.withSection(SectionFactory.defaultT2("slackChannelId", "slackChannelName", "isPrivate", "botIsMember", "isArchived", "description", "checkedAt"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Metadata for slack_user_group.
*******************************************************************************/
private static QTableMetaData userGroup(SlackQBitConfig config)
{
QTableMetaData table = base(config, USER_GROUP, "User Groups")
.withField(field("name", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("label", QFieldType.STRING).withMaxLength(500))
.withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true))
.withField(field("slackUsergroupId", QFieldType.STRING).withMaxLength(500))
.withField(field("handle", QFieldType.STRING).withMaxLength(500))
.withField(field("channelId", QFieldType.STRING).withMaxLength(500))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true));
table.withRecordLabelFormatAndFields("%s", "name");
table.withUniqueKey(new UniqueKey("workspaceId", "name"));
table.withSection(SectionFactory.defaultT1("id", "name", "label", "workspaceId", "enabled"));
table.withSection(SectionFactory.defaultT2("slackUsergroupId", "handle", "channelId"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Metadata for slack_template.
*******************************************************************************/
private static QTableMetaData template(SlackQBitConfig config)
{
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).withIsEditable(false))
.withField(field("category", QFieldType.STRING).withMaxLength(500))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true))
.withField(field("tenantKey", QFieldType.STRING).withMaxLength(500))
.withField(field("text", QFieldType.TEXT))
.withField(field("blocksTemplate", QFieldType.TEXT))
.withField(field("variablesSchema", QFieldType.TEXT));
table.withRecordLabelFormatAndFields("%s", "name");
table.withUniqueKey(new UniqueKey("name"));
table.withSection(SectionFactory.defaultT1("id", "name", "label", "category", "enabled", "tenantKey"));
table.withSection(SectionFactory.defaultT2("description", "text", "blocksTemplate", "variablesSchema"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** Metadata for slack_message.
*******************************************************************************/
private static QTableMetaData message(SlackQBitConfig config)
{
QTableMetaData table = base(config, MESSAGE, "Messages")
.withField(field("ownerUserId", QFieldType.STRING).withIsRequired(true).withMaxLength(500))
.withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE).withIsRequired(true))
.withField(field("connectionId", QFieldType.INTEGER).withPossibleValueSourceName(CONNECTION))
.withField(field("postAs", QFieldType.STRING).withPossibleValueSourceName("slack_post_as").withMaxLength(500))
.withField(field("operation", QFieldType.STRING).withPossibleValueSourceName("slack_message_operation").withDefaultValue("POST").withMaxLength(500))
.withField(field("targetType", QFieldType.STRING).withPossibleValueSourceName("slack_target_type").withMaxLength(500))
.withField(field("targetRef", QFieldType.STRING).withMaxLength(500))
.withField(field("slackChannelId", QFieldType.STRING).withMaxLength(500))
.withField(field("channelLabel", QFieldType.STRING).withMaxLength(500))
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("slack_message_status").withDefaultValue("QUEUED").withMaxLength(500))
.withField(field("category", QFieldType.STRING).withMaxLength(500))
.withField(field("templateName", QFieldType.STRING).withMaxLength(500))
.withField(field("text", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock()))
.withField(field("blocksJson", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock()))
.withField(field("variablesSnapshot", QFieldType.TEXT).withIsSensitive(true).withFieldSecurityLock(SlackSecurity.bodyFieldLock()))
.withField(field("idempotencyKey", QFieldType.STRING).withMaxLength(500))
.withField(field("parentMessageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE))
.withField(field("threadTs", QFieldType.STRING).withMaxLength(500))
.withField(field("targetMessageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE))
.withField(field("attachmentStorageRef", QFieldType.STRING).withMaxLength(500))
.withField(field("attachmentStorageTable", QFieldType.STRING).withMaxLength(500))
.withField(field("attachmentName", QFieldType.STRING).withMaxLength(500))
.withField(field("attachmentBytes", QFieldType.LONG))
.withField(field("slackFileId", QFieldType.STRING).withMaxLength(500))
.withField(field("fileReconcileRequired", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("slackTs", QFieldType.STRING).withMaxLength(500))
.withField(field("permalink", QFieldType.TEXT))
.withField(field("attemptCount", QFieldType.INTEGER).withDefaultValue(0))
.withField(field("nextAttemptDate", QFieldType.DATE_TIME))
.withField(field("leaseExpiresDate", QFieldType.DATE_TIME))
.withField(field("lastErrorCode", QFieldType.STRING).withMaxLength(500))
.withField(field("lastError", QFieldType.STRING).withMaxLength(500))
.withField(field("sentDate", QFieldType.DATE_TIME))
.withField(field("deadLetterDate", QFieldType.DATE_TIME))
.withField(field("linkedTableName", QFieldType.STRING).withMaxLength(500))
.withField(field("linkedRecordId", QFieldType.STRING).withMaxLength(500))
.withField(field("senderName", QFieldType.STRING).withMaxLength(500))
.withField(field("deliveryLogId", QFieldType.STRING).withMaxLength(500));
table.withUniqueKey(new UniqueKey("idempotencyKey"));
table.withSection(SectionFactory.defaultT1("id", "status", "ownerUserId", "workspaceId", "targetType", "channelLabel"));
table.withSection(SectionFactory.defaultT2("connectionId", "postAs", "operation", "targetRef", "slackChannelId", "category", "templateName", "text", "blocksJson", "variablesSnapshot", "idempotencyKey", "parentMessageId", "threadTs", "targetMessageId", "attachmentStorageTable", "attachmentStorageRef", "attachmentName", "attachmentBytes", "slackFileId", "fileReconcileRequired", "slackTs", "permalink", "attemptCount", "nextAttemptDate", "leaseExpiresDate", "lastErrorCode", "lastError", "sentDate", "deadLetterDate", "linkedTableName", "linkedRecordId", "senderName", "deliveryLogId"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
table.withSection(SectionFactory.customT2("interactions", new QIcon("list")).withLabel("Interactions").withWidgetName(childWidgetName(INTERACTION)));
table.withSection(SectionFactory.customT2("attempts", new QIcon("list")).withLabel("Attempts").withWidgetName(childWidgetName(ATTEMPT)));
return (table);
}
/*******************************************************************************
** Metadata for slack_message_attempt.
*******************************************************************************/
private static QTableMetaData attempt(SlackQBitConfig config)
{
QTableMetaData table = base(config, ATTEMPT, "Message Attempts")
.withField(field("messageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE).withIsRequired(true))
.withField(field("attemptNumber", QFieldType.INTEGER).withIsRequired(true))
.withField(field("outcome", QFieldType.STRING).withPossibleValueSourceName("slack_attempt_outcome").withMaxLength(500))
.withField(field("errorCode", QFieldType.STRING).withMaxLength(500))
.withField(field("httpStatus", QFieldType.INTEGER))
.withField(field("retryAfterSeconds", QFieldType.INTEGER))
.withField(field("postedAs", QFieldType.STRING).withPossibleValueSourceName("slack_post_as").withMaxLength(500))
.withField(field("claimedBy", QFieldType.STRING).withMaxLength(500))
.withField(field("startDate", QFieldType.DATE_TIME))
.withField(field("endDate", QFieldType.DATE_TIME))
.withField(field("error", QFieldType.STRING).withMaxLength(500));
table.withUniqueKey(new UniqueKey("messageId", "attemptNumber"));
table.withSection(SectionFactory.defaultT1("id", "messageId", "attemptNumber", "outcome", "postedAs", "claimedBy"));
table.withSection(SectionFactory.defaultT2("errorCode", "httpStatus", "retryAfterSeconds", "startDate", "endDate", "error"));
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
return (table);
}
/*******************************************************************************
** The relations: workspace to its connections, channels, groups and messages;
** message to its attempts; and message to its replies (parentMessageId).
*******************************************************************************/
public static List<QJoinMetaData> joins()
{
return (List.of(
join(JOIN_WORKSPACE_CONNECTION, WORKSPACE, CONNECTION, "workspaceId"),
join(JOIN_WORKSPACE_CHANNEL, WORKSPACE, CHANNEL, "workspaceId"),
join(JOIN_WORKSPACE_USER_GROUP, WORKSPACE, USER_GROUP, "workspaceId"),
join(JOIN_WORKSPACE_MESSAGE, WORKSPACE, MESSAGE, "workspaceId"),
join(JOIN_MESSAGE_INTERACTION, MESSAGE, INTERACTION, "messageId"),
join(JOIN_WORKSPACE_INTERACTION, WORKSPACE, INTERACTION, "workspaceId"),
join(JOIN_MESSAGE_ATTEMPT, MESSAGE, ATTEMPT, "messageId"),
join(JOIN_MESSAGE_PARENT_CHILD, MESSAGE, MESSAGE, "parentMessageId")));
}
/*******************************************************************************
** Parent to child relation.
*******************************************************************************/
private static QJoinMetaData join(String name, String parent, String child, String field)
{
return (new QJoinMetaData().withName(name).withType(JoinType.ONE_TO_MANY)
.withLeftTable(parent).withRightTable(child).withJoinOn(new JoinOn("id", field)));
}
/*******************************************************************************
** Name of the child-list widget that shows a child table on its parent.
*******************************************************************************/
private static String childWidgetName(String childTable)
{
return (childTable + "_children");
}
/*******************************************************************************
** Child lists live on the parent record, never in navigation. The reply join
** (message to message) has no list: replies are reached through the parent
** link on each message.
*******************************************************************************/
public static List<QWidgetMetaData> childWidgets()
{
List<QWidgetMetaData> widgets = new ArrayList<>();
for(QJoinMetaData join : joins())
{
if(JOIN_MESSAGE_PARENT_CHILD.equals(join.getName()) || JOIN_WORKSPACE_INTERACTION.equals(join.getName()))
{
continue;
}
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(childWidgetName(join.getRightTable()))
.withMaxRows(50).getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
}
return (widgets);
}
/*******************************************************************************
** Payload-free interaction audit. The unique delivery key arbitrates retries.
*******************************************************************************/
private static QTableMetaData interaction(SlackQBitConfig config)
{
return (base(config, INTERACTION, "Interactions")
.withField(field("workspaceId", QFieldType.INTEGER).withPossibleValueSourceName(WORKSPACE))
.withField(field("messageId", QFieldType.INTEGER).withPossibleValueSourceName(MESSAGE))
.withField(field("teamId", QFieldType.STRING).withMaxLength(500))
.withField(field("slackUserId", QFieldType.STRING).withMaxLength(500))
.withField(field("userId", QFieldType.STRING).withMaxLength(500))
.withField(field("actionName", QFieldType.STRING).withMaxLength(500))
.withField(field("deliveryKey", QFieldType.STRING).withMaxLength(500))
.withField(field("error", QFieldType.STRING).withMaxLength(500))
.withField(field("outcome", QFieldType.STRING).withMaxLength(500).withPossibleValueSourceName("slack_interaction_outcome"))
.withField(field("completedDate", QFieldType.DATE_TIME))
.withUniqueKey(new UniqueKey("deliveryKey"))
.withSection(SectionFactory.defaultT1("id", "workspaceId", "messageId", "userId", "actionName", "outcome"))
.withSection(SectionFactory.defaultT2("teamId", "slackUserId", "error", "completedDate", "deliveryKey"))
.withSection(SectionFactory.defaultT3("createDate", "modifyDate")));
}
}
@@ -0,0 +1,296 @@
/*
* 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.oauth;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.security.SlackSecrets;
import com.kingsrook.qbits.slack.security.SlackTokenCipher;
import com.kingsrook.qbits.slack.security.SlackTokens;
import com.kingsrook.qbits.slack.support.SlackRuntime;
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.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.json.JSONObject;
/*******************************************************************************
** Session-authorized OAuth lifecycle. Every token write is encrypted and bound
** to an existing row. Installation privileges are rechecked at callback time.
*******************************************************************************/
public final class SlackConnect
{
public static final String BOT_SCOPES = "chat:write,im:write,channels:read,groups:read,users:read,files:write,files:read";
public static final String USER_SCOPES = "chat:write,channels:read,groups:read,im:write,files:write,files:read,users:read";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackConnect()
{
}
/*******************************************************************************
** Start only for the signed-in user, never an automated or system session.
*******************************************************************************/
public static String startUrl(String userId, String mode)
{
QSession session = session();
if(!session.getUser().getIdReference().equals(userId) || !session.hasPermission("slack.connect")
|| !("user".equals(mode) || "user+bot".equals(mode)) || "user+bot".equals(mode) && !session.hasPermission("slack.workspace.install"))
{
throw (new IllegalArgumentException("Slack connect is not permitted"));
}
SlackQBitConfig config = SlackRuntime.config();
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()))
+ "&scope=" + encode("user+bot".equals(mode) ? BOT_SCOPES : "") + "&user_scope=" + encode(USER_SCOPES)
+ "&redirect_uri=" + encode(redirect) + "&state=" + encode(state));
}
/*******************************************************************************
** Complete a one-use state. Errors are fixed codes, never provider text.
*******************************************************************************/
public static String callback(String state, String code)
{
SlackQBitConfig config = SlackRuntime.config();
try
{
QSession session = session();
String user = session.getUser().getIdReference();
String tenant = tenant(config, session);
SlackOAuthState.State verified = SlackOAuthState.consume(config, session, state, user, tenant);
if(!session.hasPermission("slack.connect") || "user+bot".equals(verified.mode()) && !session.hasPermission("slack.workspace.install"))
{
return ("not_permitted");
}
if(code == null || code.isBlank() || code.length() > 4096)
{
return ("authorization_denied");
}
SlackResponse response = SlackRuntime.client(config).oauthAccess(SlackSecrets.get(config, config.getClientIdEnvName()),
SlackSecrets.get(config, config.getClientSecretEnvName()), code, redirectUri(config));
if(!response.isOk())
{
return ("exchange_failed");
}
save(config, session, verified, response.getJson());
return (null);
}
catch(Exception e)
{
return ("connect_failed");
}
}
/*******************************************************************************
** Resolve an unambiguous tenant from the host's record locks. Multiple tenant
** selections and join-based root locks cannot safely identify an install.
*******************************************************************************/
public static String tenant(SlackQBitConfig config, QSession session)
{
String tenant = null;
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
List<Serializable> values = session.getSecurityKeyValues(lock.getSecurityKeyType());
if(values == null || values.size() != 1 || values.getFirst() == null || lock.getFieldName() == null)
{
throw (new IllegalArgumentException("Select one tenant before connecting Slack"));
}
String value = String.valueOf(values.getFirst());
if(tenant != null && !tenant.equals(value))
{
throw (new IllegalArgumentException("Ambiguous Slack tenant"));
}
tenant = value;
}
return (tenant);
}
/*******************************************************************************
** Persist identity first, then encrypt credentials with the generated ids.
** Synchronization serializes callbacks on this node; database unique keys
** remain authoritative across pods and a conflict fails closed.
*******************************************************************************/
private static synchronized void save(SlackQBitConfig config, QSession session, SlackOAuthState.State state, JSONObject json) throws QException
{
JSONObject team = json.getJSONObject("team");
JSONObject user = json.getJSONObject("authed_user");
String teamId = team.getString("id");
String slackUser = user.getString("id");
if(teamId.isBlank() || slackUser.isBlank())
{
throw (new IllegalArgumentException("Invalid Slack identity"));
}
List<QRecord> workspaces = SlackStore.queryAsCurrent(SlackTables.WORKSPACE, SlackStore.equal("teamId", teamId));
String tenant = state.tenantKey().isEmpty() ? null : state.tenantKey();
QRecord workspace = workspaces.stream().filter(row -> java.util.Objects.equals(tenant, row.getValueString("tenantKey"))).findFirst().orElse(null);
if(workspace == null)
{
workspace = new QRecord().withValue("teamId", teamId).withValue("teamName", team.optString("name", "Slack"))
.withValue("tenantKey", tenant).withValue("status", "ACTIVE");
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
{
workspace.withValue(lock.getFieldName(), session.getSecurityKeyValues(lock.getSecurityKeyType()).getFirst());
}
workspace = inserted(SlackTables.WORKSPACE, workspace);
}
Integer workspaceId = workspace.getValueInteger("id");
SlackTokenCipher cipher = new SlackTokenCipher(config);
if("user+bot".equals(state.mode()) && json.has("access_token"))
{
String token = json.getString("access_token");
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", workspaceId)
.withValue("botToken", cipher.encrypt(token, SlackTokens.workspaceBinding(workspaceId, tenant)))
.withValue("botTokenFingerprint", cipher.fingerprint(token))
.withValue("botUserId", json.optString("bot_user_id", null)).withValue("botScopes", json.optString("scope", ""))
.withValue("appId", json.optString("app_id", null)).withValue("installedByUserId", state.userId())
.withValue("installedAt", SlackRuntime.clock().instant()).withValue("status", "ACTIVE"), null);
}
List<QRecord> connections = SlackStore.queryAsCurrent(SlackTables.CONNECTION, SlackStore.equal("workspaceId", workspaceId));
QRecord connection = connections.stream().filter(row -> state.userId().equals(row.getValueString("userId"))).findFirst().orElse(null);
if(connection == null)
{
connection = inserted(SlackTables.CONNECTION, new QRecord().withValue("workspaceId", workspaceId).withValue("userId", state.userId()).withValue("status", "ACTIVE"));
}
Integer id = connection.getValueInteger("id");
String token = user.optString("access_token", null);
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", id).withValue("slackUserId", slackUser)
.withValue("userScopes", user.optString("scope", "")).withValue("status", "ACTIVE").withValue("revokedAt", null)
.withValue("userToken", token == null ? null : cipher.encrypt(token, SlackTokens.connectionBinding(id, state.userId())))
.withValue("userTokenFingerprint", token == null ? null : cipher.fingerprint(token))
.withValue("connectedAt", SlackRuntime.clock().instant()), null);
}
/*******************************************************************************
** Never continue token writes after a failed identity insert.
*******************************************************************************/
private static QRecord inserted(String table, QRecord row) throws QException
{
QRecord saved = SlackStore.insert(table, row, null);
if(saved.getValueInteger("id") == null || saved.getErrors() != null && !saved.getErrors().isEmpty())
{
throw (new QException("Slack identity storage failed"));
}
return (saved);
}
/*******************************************************************************
** Revoke a visible owned connection, or an admin's tenant-visible connection.
*******************************************************************************/
public static void disconnect(Integer id) throws QException
{
QSession session = session();
QRecord row = visible(SlackTables.CONNECTION, id);
if(!session.hasPermission("slack.admin") && (!session.hasPermission("slack.connect") || !session.getUser().getIdReference().equals(row.getValueString("userId"))))
{
throw (new QException("Slack disconnect is not permitted"));
}
SlackQBitConfig config = SlackRuntime.config();
if(SlackStore.getWithToken(SlackTables.CONNECTION, id).getValueString("userToken") != null)
{
SlackResponse response = SlackRuntime.client(config).authRevoke(SlackTokens.userToken(config, id));
if(!response.isOk() && !List.of("token_revoked", "invalid_auth").contains(response.getError()))
{
throw (new QException("Slack revocation failed"));
}
}
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", id).withValue("status", "REVOKED")
.withValue("userToken", null).withValue("userTokenFingerprint", null).withValue("revokedAt", SlackRuntime.clock().instant()), null);
}
/*******************************************************************************
** Uninstall only within the administrator's visible tenant.
*******************************************************************************/
public static void uninstall(Integer id) throws QException
{
if(!session().hasPermission("slack.admin"))
{
throw (new QException("Slack uninstall is not permitted"));
}
visible(SlackTables.WORKSPACE, id);
SlackQBitConfig config = SlackRuntime.config();
SlackResponse response = SlackRuntime.client(config).appsUninstall(SlackTokens.botToken(config, id),
SlackSecrets.get(config, config.getClientIdEnvName()), SlackSecrets.get(config, config.getClientSecretEnvName()));
if(!response.isOk())
{
throw (new QException("Slack uninstall failed"));
}
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", id).withValue("status", "UNINSTALLED")
.withValue("botToken", null).withValue("botTokenFingerprint", null), null);
}
/*******************************************************************************
** Read through normal record locks before any privileged operation.
*******************************************************************************/
public static QRecord visible(String table, Integer id) throws QException
{
List<QRecord> rows = SlackStore.queryAsCurrent(table, SlackStore.equal("id", id));
if(rows.size() != 1)
{
throw (new QException("Slack record is unavailable"));
}
return (rows.getFirst());
}
/*******************************************************************************
** Require a real signed-in user.
*******************************************************************************/
private static QSession session()
{
QSession session = QContext.getQSession();
if(session == null || session instanceof QSystemUserSession || session.getUser() == null || session.getUser().getIdReference() == null)
{
throw (new IllegalArgumentException("Sign in to connect Slack"));
}
return (session);
}
/*******************************************************************************
** The exact redirect URI shared by start and exchange.
*******************************************************************************/
private static String redirectUri(SlackQBitConfig config)
{
if(config.getPublicBaseUrl() == null)
{
throw (new IllegalArgumentException("Slack public URL is required"));
}
return (config.getPublicBaseUrl().replaceAll("/+$", "") + config.getOauthCallbackPath());
}
/*******************************************************************************
** Encode one query parameter.
*******************************************************************************/
private static String encode(String value)
{
return (URLEncoder.encode(value, StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,143 @@
/*
* 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.oauth;
import java.util.Map;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
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.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import io.javalin.config.JavalinConfig;
import io.javalin.http.Context;
/*******************************************************************************
** Browser callback through the RC.6 qbit route extension point.
*******************************************************************************/
public class SlackOAuthRouteProvider implements QJavalinRouteProviderInterface
{
private QInstance instance;
/*******************************************************************************
** Idempotent provider registration.
*******************************************************************************/
public static void register(QInstance instance)
{
QJavalinMetaData metadata = QJavalinMetaData.ofOrWithNew(instance);
if(metadata.getAdditionalRouteProviderReferences() == null || metadata.getAdditionalRouteProviderReferences().stream()
.noneMatch(r -> com.kingsrook.qbits.slack.interactivity.SlackCallbackRouteProvider.class.getName().equals(r.getName())))
{
metadata.withAdditionalRouteProviderReference(new QCodeReference(com.kingsrook.qbits.slack.interactivity.SlackCallbackRouteProvider.class));
}
if(metadata.getAdditionalRouteProviderReferences() == null || metadata.getAdditionalRouteProviderReferences().stream()
.noneMatch(r -> SlackOAuthRouteProvider.class.getName().equals(r.getName())))
{
metadata.withAdditionalRouteProviderReference(new QCodeReference(SlackOAuthRouteProvider.class));
}
}
/*******************************************************************************
** Receive the host instance.
*******************************************************************************/
@Override
public void setQInstance(QInstance instance)
{
this.instance = instance;
}
/*******************************************************************************
** Register the configured callback path only.
*******************************************************************************/
@Override
public void acceptJavalinConfig(JavalinConfig config)
{
config.routes.get(SlackRuntime.config(instance).getOauthCallbackPath(), this::callback);
}
/*******************************************************************************
** Seed templates when the host starts, after its storage is initialized.
** The scheduler repeats this idempotently for non-Javalin hosts.
*******************************************************************************/
@Override
public void acceptJavalinService(io.javalin.Javalin service)
{
CapturedContext previous = QContext.capture();
try
{
QContext.init(instance, new com.kingsrook.qqq.backend.core.model.session.QSystemUserSession(instance));
com.kingsrook.qbits.slack.template.SlackTemplates.seed();
}
catch(Exception e)
{
throw (new IllegalStateException("Slack template startup failed"));
}
finally
{
QContext.init(previous);
}
}
/*******************************************************************************
** Authenticate from the existing cookie only. The generic session helper
** prioritizes code/state query parameters, which here belong to Slack,
** not the host identity provider. Restore thread context on every exit.
*******************************************************************************/
private void callback(Context context)
{
CapturedContext previous = QContext.capture();
String error = "sign_in_required";
try
{
QContext.init(instance, null);
String cookie = context.cookie("sessionUUID");
String key = "sessionUUID";
if(cookie == null)
{
cookie = context.cookie("sessionId");
key = "sessionId";
}
if(cookie != null && !cookie.isBlank())
{
var module = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication());
var session = module.createSession(instance, Map.of(key, cookie));
if(module.isSessionValid(instance, session))
{
QContext.init(instance, session);
error = SlackConnect.callback(context.queryParam("state"), context.queryParam("code"));
}
}
}
catch(Exception e)
{
error = "connect_failed";
}
finally
{
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));
}
}
@@ -0,0 +1,151 @@
/*
* 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.oauth;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.security.SlackSecrets;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONObject;
/*******************************************************************************
** Ten-minute state verified statelessly across pods, bound to the user, tenant
** and host session UUID. The bounded map tracks consumed nonces only, providing
** best-effort replay rejection on this pod. Slack authorization codes are
** single-use and provide the cross-pod replay barrier.
*******************************************************************************/
public final class SlackOAuthState
{
private static final Map<String, Long> NONCES = new HashMap<>();
private static final int CAPACITY = 10_000;
/*******************************************************************************
** Verified state contains identities, never credentials.
*******************************************************************************/
public record State(String userId, String tenantKey, String nonce, String mode, long expiry)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackOAuthState()
{
}
/*******************************************************************************
** Issue signed state without requiring pod-local or session nonce storage.
*******************************************************************************/
public static synchronized String issue(SlackQBitConfig config, QSession session, String user, String tenant, String mode)
{
long now = SlackRuntime.clock().instant().getEpochSecond();
NONCES.values().removeIf(expiry -> expiry <= now);
if(!java.util.List.of("user", "user+bot").contains(mode))
{
throw (new IllegalArgumentException("Slack connect unavailable"));
}
String nonce = UUID.randomUUID().toString();
long expiry = now + 600;
String payload = encode(new JSONObject().put("user", user).put("tenant", Objects.toString(tenant, ""))
.put("session", session.getUuid()).put("nonce", nonce).put("mode", mode).put("expiry", expiry).toString().getBytes(StandardCharsets.UTF_8));
String signed = payload + "." + encode(sign(config, payload));
return (signed);
}
/*******************************************************************************
** Verify identity and signature before atomically consuming the nonce.
*******************************************************************************/
public static synchronized State consume(SlackQBitConfig config, QSession session, String token, String user, String tenant)
{
try
{
if(token == null || token.length() > 8192 || session == null)
{
throw (new IllegalArgumentException());
}
String[] parts = token.split("\\.", -1);
if(parts.length != 2 || !MessageDigest.isEqual(sign(config, parts[0]), Base64.getUrlDecoder().decode(parts[1])))
{
throw (new IllegalArgumentException());
}
JSONObject json = new JSONObject(new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8));
State state = new State(json.getString("user"), json.getString("tenant"), json.getString("nonce"), json.getString("mode"), json.getLong("expiry"));
long now = SlackRuntime.clock().instant().getEpochSecond();
NONCES.values().removeIf(expiry -> expiry <= now);
if(!state.userId().equals(user) || !state.tenantKey().equals(Objects.toString(tenant, "")) || state.expiry() <= now
|| !Objects.equals(json.getString("session"), session.getUuid()) || NONCES.containsKey(state.nonce()))
{
throw (new IllegalArgumentException());
}
if(NONCES.size() >= CAPACITY)
{
NONCES.remove(NONCES.keySet().iterator().next());
}
NONCES.put(state.nonce(), state.expiry());
return (state);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Invalid or expired Slack state"));
}
}
/*******************************************************************************
** HKDF-style domain separation prevents token-key reuse across protocols.
*******************************************************************************/
private static byte[] sign(SlackQBitConfig config, String payload)
{
try
{
byte[] key = Base64.getDecoder().decode(SlackSecrets.get(config, config.getTokenKeyEnvName()));
if(key.length != 32)
{
throw (new IllegalArgumentException());
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
byte[] derived = mac.doFinal("slack-oauth-state".getBytes(StandardCharsets.UTF_8));
mac.init(new SecretKeySpec(derived, "HmacSHA256"));
return (mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Slack signing unavailable"));
}
}
/*******************************************************************************
** URL-safe encoding without padding.
*******************************************************************************/
private static String encode(byte[] bytes)
{
return (Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
}
}
@@ -0,0 +1,447 @@
/*
* 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.outbox;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
/*******************************************************************************
** One Slack message to queue (docs/DESIGN.md 4.3). The owner is a QQQ user id
** (when null, the signed-in user). The address uses the forms in 4.2. The text
** is plain: the service escapes it. Raw Block Kit JSON needs the slack.send.blocks
** permission. tenantKey is for an ownerless send from a system session only.
*******************************************************************************/
public class SlackSendRequest
{
private java.util.List<String> actions = java.util.List.of();
/*******************************************************************************
** Registered action names.
*******************************************************************************/
public java.util.List<String> getActions()
{
return (actions);
}
/*******************************************************************************
** Snapshot the requested actions.
*******************************************************************************/
public SlackSendRequest withActions(java.util.List<String> values)
{
actions = java.util.List.copyOf(values);
return (this);
}
/*******************************************************************************
** A storage reference to stream later, in the owner's session.
*******************************************************************************/
public record Attachment(String table, String reference, String name, Long bytes)
{
}
private java.util.List<Attachment> attachments = java.util.List.of();
/*******************************************************************************
** Attachments are persisted atomically with the parent post.
*******************************************************************************/
public java.util.List<Attachment> getAttachments()
{
return (attachments);
}
/*******************************************************************************
** Supply immutable attachment descriptors.
*******************************************************************************/
public SlackSendRequest withAttachments(java.util.List<Attachment> value)
{
attachments = java.util.List.copyOf(value);
return (this);
}
private String templateName = null;
private java.util.Map<String, java.io.Serializable> variables = java.util.Map.of();
private String contentClassification = null;
private String title = null;
private String link = null;
private String ownerUserId;
private String tenantKey;
private String to;
private String text;
private String blocksJson;
private String idempotencyKey;
private String category;
private SlackEnums.PostAs postAs;
private Integer workspaceId;
private Integer threadOfMessageId;
private String linkedTableName;
private String linkedRecordId;
private String senderName;
/*******************************************************************************
** Getter for ownerUserId
*******************************************************************************/
public String getOwnerUserId()
{
return (this.ownerUserId);
}
/*******************************************************************************
** Fluent setter for ownerUserId
*******************************************************************************/
public SlackSendRequest withOwnerUserId(String ownerUserId)
{
this.ownerUserId = ownerUserId;
return (this);
}
/*******************************************************************************
** Getter for tenantKey
*******************************************************************************/
public String getTenantKey()
{
return (this.tenantKey);
}
/*******************************************************************************
** Fluent setter for tenantKey
*******************************************************************************/
public SlackSendRequest withTenantKey(String tenantKey)
{
this.tenantKey = tenantKey;
return (this);
}
/*******************************************************************************
** Getter for to
*******************************************************************************/
public String getTo()
{
return (this.to);
}
/*******************************************************************************
** Fluent setter for to
*******************************************************************************/
public SlackSendRequest withTo(String to)
{
this.to = to;
return (this);
}
/*******************************************************************************
** Getter for text
*******************************************************************************/
public String getText()
{
return (this.text);
}
/*******************************************************************************
** Fluent setter for text
*******************************************************************************/
public SlackSendRequest withText(String text)
{
this.text = text;
return (this);
}
/*******************************************************************************
** Getter for blocksJson
*******************************************************************************/
public String getBlocksJson()
{
return (this.blocksJson);
}
/*******************************************************************************
** Fluent setter for blocksJson
*******************************************************************************/
public SlackSendRequest withBlocksJson(String blocksJson)
{
this.blocksJson = blocksJson;
return (this);
}
/*******************************************************************************
** Getter for idempotencyKey
*******************************************************************************/
public String getIdempotencyKey()
{
return (this.idempotencyKey);
}
/*******************************************************************************
** Fluent setter for idempotencyKey
*******************************************************************************/
public SlackSendRequest withIdempotencyKey(String idempotencyKey)
{
this.idempotencyKey = idempotencyKey;
return (this);
}
/*******************************************************************************
** Getter for category
*******************************************************************************/
public String getCategory()
{
return (this.category);
}
/*******************************************************************************
** Fluent setter for category
*******************************************************************************/
public SlackSendRequest withCategory(String category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for postAs
*******************************************************************************/
public SlackEnums.PostAs getPostAs()
{
return (this.postAs);
}
/*******************************************************************************
** Fluent setter for postAs
*******************************************************************************/
public SlackSendRequest withPostAs(SlackEnums.PostAs postAs)
{
this.postAs = postAs;
return (this);
}
/*******************************************************************************
** Getter for workspaceId
*******************************************************************************/
public Integer getWorkspaceId()
{
return (this.workspaceId);
}
/*******************************************************************************
** Fluent setter for workspaceId
*******************************************************************************/
public SlackSendRequest withWorkspaceId(Integer workspaceId)
{
this.workspaceId = workspaceId;
return (this);
}
/*******************************************************************************
** Getter for threadOfMessageId
*******************************************************************************/
public Integer getThreadOfMessageId()
{
return (this.threadOfMessageId);
}
/*******************************************************************************
** Fluent setter for threadOfMessageId: this message replies in the thread of
** an earlier message of the same owner.
*******************************************************************************/
public SlackSendRequest withThreadOfMessageId(Integer threadOfMessageId)
{
this.threadOfMessageId = threadOfMessageId;
return (this);
}
/*******************************************************************************
** Getter for linkedTableName
*******************************************************************************/
public String getLinkedTableName()
{
return (this.linkedTableName);
}
/*******************************************************************************
** Getter for linkedRecordId
*******************************************************************************/
public String getLinkedRecordId()
{
return (this.linkedRecordId);
}
/*******************************************************************************
** Fluent setter for the record this message is about.
*******************************************************************************/
public SlackSendRequest withLinkedRecord(String tableName, String recordId)
{
this.linkedTableName = tableName;
this.linkedRecordId = recordId;
return (this);
}
/*******************************************************************************
** Getter for senderName
*******************************************************************************/
public String getSenderName()
{
return (this.senderName);
}
/*******************************************************************************
** Fluent setter for senderName
*******************************************************************************/
public SlackSendRequest withSenderName(String senderName)
{
this.senderName = senderName;
return (this);
}
/*******************************************************************************
** Getter for templateName.
*******************************************************************************/
public String getTemplateName()
{
return (templateName);
}
/*******************************************************************************
** Fluent setter for templateName.
*******************************************************************************/
public SlackSendRequest withTemplateName(String value)
{
templateName = value;
return (this);
}
/*******************************************************************************
** Getter for variables.
*******************************************************************************/
public java.util.Map<String, java.io.Serializable> getVariables()
{
return (variables);
}
/*******************************************************************************
** Fluent setter for variables.
*******************************************************************************/
public SlackSendRequest withVariables(java.util.Map<String, java.io.Serializable> value)
{
variables = value;
return (this);
}
/*******************************************************************************
** Getter for contentClassification.
*******************************************************************************/
public String getContentClassification()
{
return (contentClassification);
}
/*******************************************************************************
** Fluent setter for contentClassification.
*******************************************************************************/
public SlackSendRequest withContentClassification(String value)
{
contentClassification = value;
return (this);
}
/*******************************************************************************
** Getter for title.
*******************************************************************************/
public String getTitle()
{
return (title);
}
/*******************************************************************************
** Fluent setter for title.
*******************************************************************************/
public SlackSendRequest withTitle(String value)
{
title = value;
return (this);
}
/*******************************************************************************
** Getter for link.
*******************************************************************************/
public String getLink()
{
return (link);
}
/*******************************************************************************
** Fluent setter for link.
*******************************************************************************/
public SlackSendRequest withLink(String value)
{
link = value;
return (this);
}
}
@@ -0,0 +1,55 @@
/*
* 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.outbox;
import java.util.List;
/*******************************************************************************
** What queueing a message did: QUEUED with the new id, DUPLICATE with the id
** of the earlier message that has the same idempotency key, or REFUSED with
** reasons fit for the user. For sendOnce, status is QUEUED_OR_DUPLICATE or
** REFUSED (see SlackService.sendOnce).
*******************************************************************************/
public record SlackSendResult(Status status, Integer messageId, List<String> reasons)
{
/*******************************************************************************
** The outcomes.
*******************************************************************************/
public enum Status
{
QUEUED,
DUPLICATE,
REFUSED,
QUEUED_OR_DUPLICATE
}
/*******************************************************************************
** A refusal with one reason.
*******************************************************************************/
public static SlackSendResult refused(String reason)
{
return (new SlackSendResult(Status.REFUSED, null, List.of(reason)));
}
}
@@ -0,0 +1,659 @@
/*
* 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.outbox;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.security.AuthorizedSend;
import com.kingsrook.qbits.slack.security.SlackAuthorization;
import com.kingsrook.qbits.slack.security.SlackAuthorizer;
import com.kingsrook.qbits.slack.security.SlackOwnerSession;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qbits.slack.template.SlackTemplates;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.json.JSONArray;
import org.json.JSONException;
/*******************************************************************************
** The validated entry point for queueing a Slack message (docs/DESIGN.md 2, 4
** and 5). It never talks to Slack to deliver: it checks the request, asks
** SlackAuthorizer whether the owner may post to the target (which does read
** channel membership from Slack), renders the text, and inserts the
** slack_message row for the dispatcher.
**
** Who the owner is:
** - a user session: the signed-in user. A request may not name anyone else;
** - a system session: the owner the request names. With none, the send is
** ownerless, which is allowed only to a registered channel and only with a
** tenant key (SlackAuthorizer).
*******************************************************************************/
public final class SlackService
{
public static final int MAX_TEXT_LENGTH = 40_000;
public static final int MAX_BLOCKS = 50;
public static final int MAX_KEY_LENGTH = 500;
public static final String PERMISSION_SEND = "slack.send";
public static final String PERMISSION_BLOCKS = "slack.send.blocks";
private static final List<String> NOT_DELIVERED = List.of("DEAD_LETTER", "FAILED_AUTH", "REFUSED", "CANCELLED", "SUPPRESSED");
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackService()
{
}
/*******************************************************************************
** Queue a message: validate, authorize, render, dedupe by idempotency key,
** and insert it QUEUED (or WAITING_PARENT, for a thread reply whose parent
** hasn't been sent yet).
*******************************************************************************/
public static SlackSendResult enqueue(SlackSendRequest request) throws QException
{
SlackQBitConfig config = SlackRuntime.config();
if(config == null)
{
return (SlackSendResult.refused("The Slack qbit isn't installed"));
}
if(request == null)
{
return (SlackSendResult.refused("A Slack message request is required"));
}
String problem = validate(request, true);
if(problem != null)
{
return (SlackSendResult.refused(problem));
}
for(SlackSendRequest.Attachment attachment : request.getAttachments())
{
if(attachment.table() == null || attachment.reference() == null || attachment.name() == null
|| attachment.bytes() == null || attachment.bytes() <= 0 || attachment.bytes() > config.getMaxAttachmentBytes())
{
return (SlackSendResult.refused("An attachment is missing its storage reference or exceeds the Slack size limit"));
}
try
{
com.kingsrook.qbits.slack.phi.SlackPayloadGuard.text(attachment.name(), request.getContentClassification());
}
catch(IllegalArgumentException e)
{
return (SlackSendResult.refused("Invalid or restricted Slack attachment"));
}
}
///////////////////////////////////////////////////////////////////////
// who is sending: a user sends only as themselves //
///////////////////////////////////////////////////////////////////////
String owner = resolveOwner(request);
if(owner != null && owner.isEmpty())
{
return (SlackSendResult.refused("Slack messages can only be sent as the signed-in user"));
}
boolean ownerless = owner == null;
String storedOwner = ownerless ? SlackAuthorizer.systemOwner(request.getTenantKey()) : owner;
String denied = permissionProblem(owner, !"SECURITY".equals(request.getCategory()) && request.getBlocksJson() != null && !request.getBlocksJson().isBlank());
if(denied != null)
{
return (SlackSendResult.refused(denied));
}
SlackSendResult duplicate = duplicate(request.getIdempotencyKey(), storedOwner);
if(duplicate != null)
{
return (duplicate);
}
///////////////////////////////////////////////////////////////////////////////////
// a reply takes its target from the parent, which the owner's session must see //
///////////////////////////////////////////////////////////////////////////////////
String address = request.getTo();
Integer workspaceId = request.getWorkspaceId();
QRecord parent = null;
if(request.getThreadOfMessageId() != null)
{
if(ownerless)
{
return (SlackSendResult.refused("A thread reply needs an owner"));
}
parent = ownerMessage(owner, request.getThreadOfMessageId());
if(parent == null)
{
return (SlackSendResult.refused("That message to reply to wasn't found"));
}
if(!SlackEnums.Operation.POST.name().equals(parent.getValueString("operation")) || NOT_DELIVERED.contains(parent.getValueString("status")))
{
return (SlackSendResult.refused("A reply needs a message that was posted or is waiting to be posted"));
}
address = address != null ? address : parent.getValueString("targetRef");
workspaceId = parent.getValueInteger("workspaceId");
}
SlackAuthorization authorization = new SlackAuthorizer().authorize(new SlackAuthorizer.Request()
.withOwnerUserId(owner).withTenantKey(request.getTenantKey()).withAddress(address).withWorkspaceId(workspaceId).withPostAs(request.getPostAs()));
if(!authorization.isAuthorized())
{
return (SlackSendResult.refused(authorization.getRefusal().message()));
}
AuthorizedSend send = authorization.getSend();
QRecord row;
try
{
com.kingsrook.qbits.slack.interactivity.SlackActions.validate(request);
row = baseRow(request, storedOwner, send, SlackEnums.Operation.POST);
}
catch(IllegalArgumentException e)
{
return (SlackSendResult.refused(e instanceof com.kingsrook.qbits.slack.phi.SlackPayloadGuard.RestrictedVariableException ? e.getMessage() : "Invalid or restricted Slack content"));
}
if(parent != null)
{
boolean parentSent = "SENT".equals(parent.getValueString("status")) && parent.getValueString("slackTs") != null;
row.withValue("parentMessageId", parent.getValueInteger("id"));
row.withValue("threadTs", parentSent ? parent.getValueString("slackTs") : null);
row.withValue("status", parentSent ? SlackEnums.MessageStatus.QUEUED.name() : SlackEnums.MessageStatus.WAITING_PARENT.name());
if(!parentSent)
{
row.withValue("nextAttemptDate", null);
}
}
if(!request.getActions().isEmpty())
{
return (insertActions(row, request));
}
return (request.getAttachments().isEmpty() ? insert(row) : insertDelivery(row, request.getAttachments()));
}
/*******************************************************************************
** Commit a post and its waiting files together. A duplicate key never adds
** children to an earlier delivery; a failed child rolls back the whole set.
*******************************************************************************/
private static SlackSendResult insertDelivery(QRecord row, List<SlackSendRequest.Attachment> attachments) throws QException
{
try(var tx = com.kingsrook.qqq.backend.core.actions.QBackendTransaction.openFor(
new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(SlackTables.MESSAGE)))
{
QRecord parent = SlackStore.insert(SlackTables.MESSAGE, row, tx);
if(parent.getValueInteger("id") == null || parent.getErrors() != null && !parent.getErrors().isEmpty())
{
throw (new QException("Could not queue Slack delivery"));
}
for(SlackSendRequest.Attachment attachment : attachments)
{
QRecord file = new QRecord();
for(String field : List.of("ownerUserId", "workspaceId", "connectionId", "postAs", "targetType", "targetRef", "slackChannelId", "channelLabel"))
{
file.withValue(field, row.getValue(field));
}
file.withValue("operation", "FILE").withValue("status", "WAITING_PARENT").withValue("attemptCount", 0)
.withValue("parentMessageId", parent.getValueInteger("id"))
.withValue("attachmentStorageTable", attachment.table()).withValue("attachmentStorageRef", attachment.reference())
.withValue("attachmentName", attachment.name()).withValue("attachmentBytes", attachment.bytes());
QRecord inserted = SlackStore.insert(SlackTables.MESSAGE, file, tx);
if(inserted.getValueInteger("id") == null || inserted.getErrors() != null && !inserted.getErrors().isEmpty())
{
throw (new QException("Could not queue Slack attachment"));
}
}
tx.commit();
return (new SlackSendResult(SlackSendResult.Status.QUEUED, parent.getValueInteger("id"), List.of()));
}
catch(QException e)
{
SlackSendResult existing = duplicate(row.getValueString("idempotencyKey"), row.getValueString("ownerUserId"));
if(existing != null)
{
return (existing);
}
throw (new QException("Could not queue Slack delivery"));
}
}
/*******************************************************************************
** Queue once, for a durable handoff keyed by an effect key (the jobs effect
** primitive): the key becomes the idempotency key, a repeat gives
** QUEUED_OR_DUPLICATE with the same id, and the row is committed before this
** returns. It refuses a caller transaction, because a handoff that can roll
** back with the caller isn't a handoff. The request's idempotency key is
** replaced by the effect key.
*******************************************************************************/
public static SlackSendResult sendOnce(SlackSendRequest request, String effectKey) throws QException
{
if(QContext.getQBackendTransaction() != null)
{
return (SlackSendResult.refused("sendOnce can't run inside a caller transaction"));
}
if(effectKey == null || effectKey.isBlank())
{
return (SlackSendResult.refused("An effect key is required"));
}
if(request == null)
{
return (SlackSendResult.refused("A Slack message request is required"));
}
SlackSendResult result = enqueue(request.withIdempotencyKey(effectKey));
if(result.status() == SlackSendResult.Status.QUEUED || result.status() == SlackSendResult.Status.DUPLICATE)
{
return (new SlackSendResult(SlackSendResult.Status.QUEUED_OR_DUPLICATE, result.messageId(), result.reasons()));
}
return (result);
}
/*******************************************************************************
** Queue a chat.update of a message that was sent. The new row has the same
** owner, workspace, target, channel and posting identity as the sent one,
** because Slack only lets a token edit its own messages.
*******************************************************************************/
public static SlackSendResult update(Integer messageId, SlackSendRequest request) throws QException
{
return (update(messageId, request, false));
}
/*******************************************************************************
** Build only the trusted completion replacement; public requests still require
** the raw-block permission. The original owner is reauthorized for the update.
*******************************************************************************/
public static SlackSendResult updateInteraction(QRecord message, int interactionId, String context) throws QException
{
com.kingsrook.qbits.slack.phi.SlackPayloadGuard.text(context, null);
JSONArray blocks = message.getValueString("blocksJson") == null ? new JSONArray() : new JSONArray(message.getValueString("blocksJson"));
JSONArray replacement = new JSONArray();
for(int i = 0; i < blocks.length(); i++)
{
if(!"actions".equals(blocks.getJSONObject(i).optString("type")))
{
replacement.put(blocks.getJSONObject(i));
}
}
replacement.put(new org.json.JSONObject().put("type", "context").put("elements", new JSONArray()
.put(new org.json.JSONObject().put("type", "plain_text").put("text", context))));
return (SlackStore.asSystem(() -> update(message.getValueInteger("id"), new SlackSendRequest()
.withOwnerUserId(message.getValueString("ownerUserId")).withText(message.getValueString("text"))
.withBlocksJson(replacement.toString()).withIdempotencyKey("interaction:" + interactionId), true)));
}
/*******************************************************************************
** Shared authorization and outbox path for updates.
*******************************************************************************/
private static SlackSendResult update(Integer messageId, SlackSendRequest request, boolean generatedBlocks) throws QException
{
SlackQBitConfig config = SlackRuntime.config();
if(config == null)
{
return (SlackSendResult.refused("The Slack qbit isn't installed"));
}
if(request == null || messageId == null)
{
return (SlackSendResult.refused("A message and its new content are required"));
}
String problem = validate(request, false);
if(problem != null)
{
return (SlackSendResult.refused(problem));
}
String owner = resolveOwner(request);
if(owner == null || owner.isEmpty())
{
return (SlackSendResult.refused("Only the owner can update a Slack message"));
}
String denied = permissionProblem(owner, !generatedBlocks && !"SECURITY".equals(request.getCategory()) && request.getBlocksJson() != null && !request.getBlocksJson().isBlank());
if(denied != null)
{
return (SlackSendResult.refused(denied));
}
QRecord target;
if(QContext.getQSession().hasPermission("slack.admin"))
{
List<QRecord> visible = SlackStore.queryAsCurrent(SlackTables.MESSAGE, SlackStore.equal("id", messageId));
target = visible.isEmpty() ? null : visible.getFirst();
}
else
{
target = ownerMessage(owner, messageId);
}
if(target == null)
{
return (SlackSendResult.refused("That message wasn't found"));
}
if(!"SENT".equals(target.getValueString("status")) || target.getValueString("slackTs") == null || !SlackEnums.Operation.POST.name().equals(target.getValueString("operation")))
{
return (SlackSendResult.refused("Only a message that was sent can be updated"));
}
String originalOwner = target.getValueString("ownerUserId");
if(!owner.equals(originalOwner))
{
if(!QContext.getQSession().hasPermission("slack.admin") || SlackAuthorizer.isOwnerless(originalOwner))
{
return (SlackSendResult.refused("Only the owner or tenant admin can update a Slack message"));
}
owner = originalOwner;
}
SlackEnums.PostAs postAs = SlackEnums.PostAs.valueOf(target.getValueString("postAs"));
SlackAuthorization authorization = new SlackAuthorizer().authorize(new SlackAuthorizer.Request()
.withOwnerUserId(owner).withAddress(target.getValueString("targetRef")).withWorkspaceId(target.getValueInteger("workspaceId")).withPostAs(postAs));
if(!authorization.isAuthorized())
{
return (SlackSendResult.refused(authorization.getRefusal().message()));
}
QRecord row;
try
{
row = baseRow(request, owner, authorization.getSend(), SlackEnums.Operation.UPDATE);
}
catch(IllegalArgumentException e)
{
return (SlackSendResult.refused(e instanceof com.kingsrook.qbits.slack.phi.SlackPayloadGuard.RestrictedVariableException ? e.getMessage() : "Invalid or restricted Slack content"));
}
row.withValue("targetMessageId", messageId);
row.withValue("slackTs", target.getValueString("slackTs"));
row.withValue("slackChannelId", target.getValueString("slackChannelId"));
return (insert(row));
}
/*******************************************************************************
** Escape the three characters Slack treats as markup, so text can't inject a
** mention, a link or a channel reference.
*******************************************************************************/
public static String escape(String text)
{
return (text == null ? null : text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"));
}
/*******************************************************************************
** Check the parts of a request that need no lookup. Returns the problem, or
** null. A new message needs an address (unless it is a reply); an update
** doesn't.
*******************************************************************************/
private static String validate(SlackSendRequest request, boolean needsAddress)
{
boolean hasText = !"SECURITY".equals(request.getCategory()) && request.getText() != null && !request.getText().isBlank();
boolean hasBlocks = !"SECURITY".equals(request.getCategory()) && request.getBlocksJson() != null && !request.getBlocksJson().isBlank();
if(!hasText && !hasBlocks && request.getTemplateName() == null && !"SECURITY".equals(request.getCategory()))
{
return ("A Slack message needs text or blocks");
}
if(hasText && request.getText().length() > MAX_TEXT_LENGTH)
{
return ("The Slack message text is too long");
}
if(hasBlocks)
{
if(request.getBlocksJson().length() > MAX_TEXT_LENGTH)
{
return ("The Slack blocks are too long");
}
try
{
int blocks = new JSONArray(request.getBlocksJson()).length();
if(blocks == 0 || blocks > MAX_BLOCKS)
{
return ("Slack blocks must be an array of 1 to " + MAX_BLOCKS + " blocks");
}
}
catch(JSONException e)
{
return ("Slack blocks must be a JSON array");
}
}
if(request.getIdempotencyKey() != null && request.getIdempotencyKey().length() > MAX_KEY_LENGTH)
{
return ("The idempotency key is too long");
}
if(needsAddress && (request.getTo() == null || request.getTo().isBlank()) && request.getThreadOfMessageId() == null)
{
return ("A Slack address is required");
}
return (null);
}
/*******************************************************************************
** The owner a request is for. Returns null for an ownerless system send, and
** the empty string when a user session names someone else (or has no user).
*******************************************************************************/
private static String resolveOwner(SlackSendRequest request)
{
QSession caller = QContext.getQSession();
String callerId = caller == null || caller.getUser() == null ? null : caller.getUser().getIdReference();
String named = request.getOwnerUserId();
if(caller instanceof QSystemUserSession)
{
return (named == null || named.isBlank() ? null : named);
}
if(callerId == null || (named != null && !named.equals(callerId)))
{
return ("");
}
return (callerId);
}
/*******************************************************************************
** The permission the owner needs, checked only when core enforces permissions
** (every UI and API call does). The owner's own session is used, so an
** automated session for an owner has the owner's permissions.
*******************************************************************************/
private static String permissionProblem(String owner, boolean blocks) throws QException
{
if(owner == null || !CorePermissionEnforcer.isInstanceEnforcementEnabled())
{
return (null);
}
QSession session = SlackOwnerSession.create(QContext.getQInstance(), owner);
if(!session.hasPermission(PERMISSION_SEND))
{
return ("Sending to Slack needs the " + PERMISSION_SEND + " permission");
}
if(blocks && !session.hasPermission(PERMISSION_BLOCKS))
{
return ("Sending Slack blocks needs the " + PERMISSION_BLOCKS + " permission");
}
return (null);
}
/*******************************************************************************
** A message of this owner, read in the owner's session, so another owner's
** or another tenant's message is simply not there.
*******************************************************************************/
private static QRecord ownerMessage(String owner, Integer messageId) throws QException
{
try
{
List<QRecord> rows = SlackOwnerSession.call(owner, () -> SlackStore.queryAsCurrent(SlackTables.MESSAGE, SlackStore.equal("id", messageId)));
return (rows.isEmpty() ? null : rows.get(0));
}
catch(QPermissionDeniedException e)
{
return (null);
}
}
/*******************************************************************************
** The DUPLICATE result for a key already used by this owner. A key used by
** someone else is refused, and never gives the other owner's message id.
*******************************************************************************/
private static SlackSendResult duplicate(String key, String storedOwner) throws QException
{
if(key == null)
{
return (null);
}
List<QRecord> rows = SlackStore.query(SlackTables.MESSAGE, SlackStore.equal("idempotencyKey", key));
if(rows.isEmpty())
{
return (null);
}
if(!storedOwner.equals(rows.get(0).getValueString("ownerUserId")))
{
return (SlackSendResult.refused("That idempotency key is already in use"));
}
return (new SlackSendResult(SlackSendResult.Status.DUPLICATE, rows.get(0).getValueInteger("id"), List.of()));
}
/*******************************************************************************
** The row common to a post and an update.
*******************************************************************************/
private static QRecord baseRow(SlackSendRequest request, String storedOwner, AuthorizedSend send, SlackEnums.Operation operation) throws QException
{
SlackTemplates.Content content = SlackAuthorizer.isOwnerless(storedOwner) ? SlackTemplates.render(request)
: SlackOwnerSession.call(storedOwner, () -> SlackTemplates.render(request));
String text = content.text();
if(text != null && text.length() > MAX_TEXT_LENGTH || content.blocks() != null && content.blocks().length() >= MAX_TEXT_LENGTH)
{
throw (new IllegalArgumentException("Slack content too large"));
}
if(text != null && send.mentionPrefix() != null)
{
text = send.mentionPrefix() + text;
}
Instant now = SlackRuntime.clock().instant();
return (new QRecord()
.withValue("ownerUserId", storedOwner)
.withValue("workspaceId", send.workspaceId())
.withValue("connectionId", send.connectionId())
.withValue("postAs", send.postAs().name())
.withValue("operation", operation.name())
.withValue("targetType", send.targetType().name())
.withValue("targetRef", send.targetRef())
.withValue("slackChannelId", send.slackChannelId())
.withValue("channelLabel", send.channelLabel())
.withValue("status", SlackEnums.MessageStatus.QUEUED.name())
.withValue("category", request.getCategory())
.withValue("text", text)
.withValue("blocksJson", content.blocks())
.withValue("templateName", request.getTemplateName())
.withValue("idempotencyKey", request.getIdempotencyKey())
.withValue("attemptCount", 0)
.withValue("nextAttemptDate", now)
.withValue("linkedTableName", request.getLinkedTableName())
.withValue("linkedRecordId", request.getLinkedRecordId())
.withValue("senderName", request.getSenderName()));
}
/*******************************************************************************
** Insert the row. The unique key on the idempotency key arbitrates a race: a
** failed insert that finds the key already there is a DUPLICATE.
*******************************************************************************/
private static SlackSendResult insert(QRecord row) throws QException
{
QRecord inserted = null;
try
{
inserted = SlackStore.insert(SlackTables.MESSAGE, row, null);
}
catch(QException e)
{
if(row.getValueString("idempotencyKey") == null)
{
throw (new QException("Could not queue the Slack message"));
}
}
if(inserted != null && inserted.getValueInteger("id") != null && (inserted.getErrors() == null || inserted.getErrors().isEmpty()))
{
return (new SlackSendResult(SlackSendResult.Status.QUEUED, inserted.getValueInteger("id"), List.of()));
}
SlackSendResult existing = duplicate(row.getValueString("idempotencyKey"), row.getValueString("ownerUserId"));
if(existing == null)
{
throw (new QException("Could not queue the Slack message"));
}
return (existing);
}
/*******************************************************************************
** Publish the row and signed action values atomically to the dispatcher.
*******************************************************************************/
private static SlackSendResult insertActions(QRecord row, SlackSendRequest request) throws QException
{
try(var tx = com.kingsrook.qqq.backend.core.actions.QBackendTransaction.openFor(
new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(SlackTables.MESSAGE)))
{
QRecord inserted = SlackStore.insert(SlackTables.MESSAGE, row, tx);
if(inserted.getValueInteger("id") == null || inserted.getErrors() != null && !inserted.getErrors().isEmpty())
{
throw (new QException("Could not queue Slack actions"));
}
row.withValue("id", inserted.getValueInteger("id"));
SlackStore.update(SlackTables.MESSAGE, new QRecord().withValue("id", inserted.getValueInteger("id"))
.withValue("blocksJson", com.kingsrook.qbits.slack.interactivity.SlackActions.blocks(row, request)), tx);
tx.commit();
return (new SlackSendResult(SlackSendResult.Status.QUEUED, inserted.getValueInteger("id"), List.of()));
}
catch(Exception e)
{
SlackSendResult existing = duplicate(row.getValueString("idempotencyKey"), row.getValueString("ownerUserId"));
return (existing == null ? SlackSendResult.refused("Could not queue Slack actions") : existing);
}
}
}
@@ -0,0 +1,125 @@
/*
* 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.permissions;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qbits.slack.security.SlackSecurity;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Table reads retain owner and tenant locks; mutations use explicit actions.
*******************************************************************************/
public class SlackPermissionChecker implements CustomPermissionChecker
{
private static final ThreadLocal<Boolean> WORKSPACE_LOOKUP = ThreadLocal.withInitial(() -> false);
/*******************************************************************************
** Internal workspace resolution retains viewer locks without granting access
** to the administrative workspace table in the UI or host API.
*******************************************************************************/
public static <T> T workspaceLookup(com.kingsrook.qbits.slack.support.SlackStore.Work<T> work) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
boolean previous = WORKSPACE_LOOKUP.get();
try
{
WORKSPACE_LOOKUP.set(true);
return (work.run());
}
finally
{
if(previous)
{
WORKSPACE_LOOKUP.set(true);
}
else
{
WORKSPACE_LOOKUP.remove();
}
}
}
/*******************************************************************************
** Authorize metadata and refresh the permission-derived body field key.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() instanceof com.kingsrook.qqq.backend.core.model.session.QSystemUserSession)
{
return;
}
SlackSecurity.applyBodyPermission(QContext.getQSession());
String needed = SlackPermissions.VIEW;
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData widget
&& com.kingsrook.qbits.slack.app.SlackApp.ADMIN_WIDGETS.contains(widget.getName()))
{
needed = SlackPermissions.ADMIN;
}
if(metadata instanceof QTableMetaData workspace && SlackTables.WORKSPACE.equals(workspace.getName()) && !WORKSPACE_LOOKUP.get())
{
needed = SlackPermissions.ADMIN;
}
if(metadata instanceof QTableMetaData table && (input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput))
{
if(SlackTables.SYSTEM_WRITTEN.contains(table.getName()) || SlackTables.CONNECTION.equals(table.getName()) || SlackTables.WORKSPACE.equals(table.getName()) && input instanceof InsertInput)
{
throw (new QPermissionDeniedException("Use a Slack record action to change this record."));
}
needed = SlackPermissions.ADMIN;
}
if(metadata instanceof QProcessMetaData process)
{
needed = switch(process.getName())
{
case SlackProcesses.CONNECT, SlackProcesses.DISCONNECT -> SlackPermissions.CONNECT;
case SlackProcesses.INSTALL -> SlackPermissions.INSTALL;
case SlackProcesses.TEST_MESSAGE -> SlackPermissions.SEND;
default -> SlackPermissions.ADMIN;
};
if(SlackProcesses.DISCONNECT.equals(process.getName()) && has(SlackPermissions.ADMIN))
{
return;
}
}
if(!has(needed))
{
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
}
}
/*******************************************************************************
** Permissions are independent; an administrator still needs a body grant.
*******************************************************************************/
public static boolean has(String permission)
{
return (QContext.getQSession() != null && QContext.getQSession().hasPermission(permission));
}
}
@@ -0,0 +1,45 @@
/*
* 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.permissions;
import java.util.List;
/*******************************************************************************
** Explicit Slack permission names; body and raw blocks are separate grants.
*******************************************************************************/
public final class SlackPermissions
{
public static final String VIEW = "slack.view";
public static final String CONNECT = "slack.connect";
public static final String INSTALL = "slack.workspace.install";
public static final String ADMIN = "slack.admin";
public static final String SEND = "slack.send";
public static final String BLOCKS = "slack.send.blocks";
public static final String BODY = "slack.message.body";
public static final List<String> ALL = List.of(VIEW, CONNECT, INSTALL, ADMIN, SEND, BLOCKS, BODY);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackPermissions()
{
}
}
@@ -0,0 +1,250 @@
/*
* 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.phi;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
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.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.
*******************************************************************************/
public final class SlackPayloadGuard
{
/*******************************************************************************
** Safe diagnostic containing only a declared schema variable name.
*******************************************************************************/
public static class RestrictedVariableException extends IllegalArgumentException
{
/*******************************************************************************
** Never include the supplied value in diagnostics.
*******************************************************************************/
public RestrictedVariableException(String name)
{
super(name == null ? "Restricted template variable" : "Restricted template variable: " + name);
}
}
/*******************************************************************************
** Runtime provenance accompanies a value without exposing it in diagnostics.
*******************************************************************************/
public record ClassifiedValue(Serializable value, String classification, String table, String field) implements Serializable
{
/*******************************************************************************
** Do not expose restricted values through accidental diagnostic formatting.
*******************************************************************************/
@Override
public String toString()
{
return ("[classified value]");
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackPayloadGuard()
{
}
/*******************************************************************************
** Resolve the most restrictive declaration and core field classification.
*******************************************************************************/
public static boolean restricted(String declared, String tableName, String field)
{
DataClassification classification = declared == null ? null : DataClassification.valueOf(declared);
if(tableName != null)
{
var table = QContext.getQInstance().getTable(tableName);
if(table == null || table.getField(field) == null)
{
throw (new IllegalArgumentException("Unknown classified field"));
}
classification = DataClassification.max(classification, AiDataClassifications.ofField(table, field));
}
return (classification != null && classification.isAtLeast(DataClassification.PII));
}
/*******************************************************************************
** A restricted schema cannot be stored, even when marked for tokenization.
*******************************************************************************/
public static void validateSchema(VariableSchema schema)
{
for(VariableSchema.Entry entry : schema.entries().values())
{
if(restricted(entry.classification(), entry.table(), entry.field()))
{
throw (new IllegalArgumentException("Restricted template variable"));
}
}
}
/*******************************************************************************
** Sanitize all supplied values before validation, rendering or snapshotting.
*******************************************************************************/
public static Map<String, Serializable> variables(VariableSchema schema, Map<String, Serializable> values)
{
Map<String, Serializable> safe = new LinkedHashMap<>();
for(Map.Entry<String, Serializable> entry : values.entrySet())
{
VariableSchema.Entry declaration = schema.entries().get(entry.getKey());
boolean tokenize = declaration != null && declaration.tokenize();
if(declaration != null && restricted(declaration.classification(), declaration.table(), declaration.field()))
{
if(!tokenize)
{
throw (new RestrictedVariableException(declaration.name()));
}
safe.put(entry.getKey(), "changed:v1");
}
else
{
try
{
safe.put(entry.getKey(), sanitize(entry.getValue(), tokenize));
}
catch(IllegalArgumentException e)
{
throw (new RestrictedVariableException(declaration == null ? null : declaration.name()));
}
}
}
for(VariableSchema.Entry declaration : schema.entries().values())
{
if(restricted(declaration.classification(), declaration.table(), declaration.field()))
{
if(!declaration.tokenize())
{
throw (new RestrictedVariableException(declaration.name()));
}
safe.put(declaration.name(), "changed:v1");
}
}
return (safe);
}
/*******************************************************************************
** Traverse nested maps and lists; no classified wrapper can stringify raw data.
*******************************************************************************/
private static Serializable sanitize(Object value, boolean tokenize)
{
if(value instanceof com.kingsrook.qbits.slack.api.SlackApiVariable variable)
{
return (sanitize(new ClassifiedValue(variable.value(), variable.classification(), variable.table(), variable.field()), tokenize));
}
if(value instanceof ClassifiedValue classified)
{
if(restricted(classified.classification(), classified.table(), classified.field()))
{
if(!tokenize)
{
throw (new IllegalArgumentException("Restricted template variable"));
}
return ("changed:v1");
}
return (sanitize(classified.value(), tokenize));
}
if(value instanceof Map<?, ?> map)
{
LinkedHashMap<String, Serializable> result = new LinkedHashMap<>();
map.forEach((key, item) -> result.put(String.valueOf(key), sanitize(item, tokenize)));
return (result);
}
if(value instanceof List<?> list)
{
java.util.ArrayList<Serializable> result = new java.util.ArrayList<>();
list.forEach(item -> result.add(sanitize(item, tokenize)));
return (result);
}
if(value != null)
{
text(value.toString(), null);
}
return ((Serializable) value);
}
/*******************************************************************************
** Inspect decoded JSON strings as well as the source, so unicode escapes
** cannot disguise a restricted free-text pattern.
*******************************************************************************/
public static void blocks(String source)
{
if(source == null || source.isBlank())
{
return;
}
try
{
inspectJson(new com.fasterxml.jackson.databind.ObjectMapper().readTree(source));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Invalid or restricted Slack blocks"));
}
}
/*******************************************************************************
** Recursively inspect textual values without rendering or logging them.
*******************************************************************************/
private static void inspectJson(com.fasterxml.jackson.databind.JsonNode node)
{
if(node.isTextual())
{
text(node.textValue(), null);
}
else
{
node.forEach(SlackPayloadGuard::inspectJson);
}
}
/*******************************************************************************
** Declared classification and configured patterns refuse free text. No value
** or pattern match appears in the diagnostic.
*******************************************************************************/
public static void text(String text, String classification)
{
if(restricted(classification, null, null))
{
throw (new IllegalArgumentException("Restricted Slack content"));
}
if(text != null)
{
for(String pattern : SlackRuntime.config().getPhiPatterns())
{
if(Pattern.compile(pattern).matcher(text).find())
{
throw (new IllegalArgumentException("Restricted Slack content"));
}
}
}
}
}
@@ -0,0 +1,125 @@
/*
* 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.util.ArrayList;
import java.util.List;
import java.util.Set;
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.SlackSendResult;
import com.kingsrook.qbits.slack.outbox.SlackService;
import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker;
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Core-independent Slack preference section and the services behind its actions.
*******************************************************************************/
public class SlackPreferences
{
/*******************************************************************************
** Read-only content and process references, shared with Connections today.
*******************************************************************************/
public record Section(String name, String label, boolean readOnly, List<QRecord> connections, List<String> actions)
{
}
/*******************************************************************************
** Explicit owner filter keeps an admin's preference list personal too.
*******************************************************************************/
public Section section() throws QException
{
List<QRecord> connections = new QueryAction().execute(new QueryInput(SlackTables.CONNECTION)
.withFilter(SlackStore.equal("userId", owner())).withFieldNamesToInclude(Set.of("id", "workspaceId", "status", "connectedAt"))).getRecords();
List<String> actions = new ArrayList<>();
if(SlackPermissionChecker.has(SlackPermissions.CONNECT))
{
actions.add(SlackProcesses.CONNECT);
actions.add(SlackProcesses.DISCONNECT);
}
if(SlackPermissionChecker.has(SlackPermissions.SEND))
{
actions.add(SlackProcesses.TEST_MESSAGE);
}
if(SlackPermissionChecker.has(SlackPermissions.INSTALL) && SlackPermissionChecker.has(SlackPermissions.CONNECT))
{
actions.add(SlackProcesses.INSTALL);
}
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)
{
return (SlackConnect.startUrl(owner(), install ? "user+bot" : "user"));
}
/*******************************************************************************
** Queue a DM through the service after reloading the selected owner connection.
*******************************************************************************/
public static SlackSendResult testMessage(Integer connectionId) throws QException
{
QRecord connection = SlackConnect.visible(SlackTables.CONNECTION, connectionId);
if(!owner().equals(connection.getValueString("userId")))
{
throw (new QException("Choose your own Slack connection for a test message."));
}
return (SlackService.enqueue(new SlackSendRequest().withOwnerUserId(owner()).withTo("me")
.withWorkspaceId(connection.getValueInteger("workspaceId")).withText("Your Slack connection is working. This is a test message from QQQ.")));
}
/*******************************************************************************
** Preference actions always belong to a signed-in human user.
*******************************************************************************/
private static String owner()
{
if(QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getIdReference() == null)
{
throw (new IllegalStateException("Sign in to manage Slack preferences."));
}
return (QContext.getQSession().getUser().getIdReference());
}
}
@@ -0,0 +1,206 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.preferences;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.oauth.SlackConnect;
import com.kingsrook.qbits.slack.outbox.SlackSendResult;
import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker;
import com.kingsrook.qbits.slack.permissions.SlackPermissions;
import com.kingsrook.qbits.slack.processes.SlackProcesses;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionRequest;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
/*******************************************************************************
** Personal Slack connections through the released core preferences SPI.
*******************************************************************************/
public class SlackPreferencesProvider implements UserPreferenceProviderInterface
{
private static final List<String> FIELDS = List.of("workspace", "status", "connectedAt");
/*******************************************************************************
** Register real metadata; configuration errors are intentionally not caught.
*******************************************************************************/
public static void register(QInstance instance)
{
UserPreferencesMetaData.ofOrWithNew(instance).withSection(new SlackSectionMetaData()
.withName("slack").withLabel("Slack").withIcon(new QIcon("forum")).withShowInUserMenu(true)
.withPermissionName(SlackPermissions.CONNECT).withProvider(new QCodeReference(SlackPreferencesProvider.class))
.withList(new UserPreferenceListMetaData().withName("connections").withLabel("Connections").withRowLabel("Connection")
.withField(new QFieldMetaData("workspace", QFieldType.STRING).withLabel("Workspace").withIsEditable(false))
.withField(new QFieldMetaData("status", QFieldType.STRING).withLabel("Status").withIsEditable(false))
.withField(new QFieldMetaData("connectedAt", QFieldType.DATE_TIME).withLabel("Connected at").withIsEditable(false)))
.withAction(action(SlackProcesses.CONNECT, "Connect Slack", UserPreferenceActionScope.SECTION))
.withAction(action(SlackProcesses.INSTALL, "Install for my organization", UserPreferenceActionScope.SECTION))
.withAction(action(SlackProcesses.TEST_MESSAGE, "Send me a test message", UserPreferenceActionScope.ROW))
.withAction(action(SlackProcesses.DISCONNECT, "Disconnect", UserPreferenceActionScope.ROW)
.withIsDestructive(true).withConfirmationText("Disconnect this Slack connection?")));
}
/*******************************************************************************
** Row actions target only the connections list.
*******************************************************************************/
private static UserPreferenceActionMetaData action(String name, String label, UserPreferenceActionScope scope)
{
return (new UserPreferenceActionMetaData().withName(name).withLabel(label).withScope(scope)
.withListName(scope == UserPreferenceActionScope.ROW ? "connections" : null));
}
/*******************************************************************************
** RC.7 has no per-action permission property. Filter a fresh view for the
** current session without mutating shared metadata or other users' actions.
*******************************************************************************/
public static class SlackSectionMetaData extends UserPreferenceSectionMetaData
{
/*******************************************************************************
** With no session, expose all registrations for startup validation.
*******************************************************************************/
@Override
public List<UserPreferenceActionMetaData> getActions()
{
return (QContext.getQSession() == null ? super.getActions() : super.getActions().stream()
.filter(action -> permitted(action.getName())).toList());
}
}
/*******************************************************************************
** Check permissions even for direct provider calls inside core's trusted scope.
*******************************************************************************/
private static boolean permitted(String action)
{
if(!SlackPermissionChecker.has(SlackPermissions.CONNECT))
{
return (false);
}
return (switch(action)
{
case SlackProcesses.CONNECT, SlackProcesses.DISCONNECT -> true;
case SlackProcesses.INSTALL -> SlackPermissionChecker.has(SlackPermissions.INSTALL);
case SlackProcesses.TEST_MESSAGE -> SlackPermissionChecker.has(SlackPermissions.SEND);
default -> false;
});
}
/*******************************************************************************
** Owner-scoped services provide only non-secret connection metadata.
*******************************************************************************/
@Override
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
{
SlackPreferences.Section section = new SlackPreferences().section();
List<UserPreferenceRow> rows = new ArrayList<>();
Set<String> rowActions = section.actions().stream()
.filter(name -> Set.of(SlackProcesses.DISCONNECT, SlackProcesses.TEST_MESSAGE).contains(name))
.collect(Collectors.toSet());
for(QRecord connection : section.connections())
{
String workspace = SlackPreferences.workspaceLabel(connection.getValueInteger("workspaceId"));
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("workspace", workspace);
values.put("status", connection.getValue("status"));
values.put("connectedAt", connection.getValue("connectedAt"));
rows.add(new UserPreferenceRow().withKey(connection.getValueString("id")).withLabel(workspace).withValues(values)
.withReadOnlyFieldNames(FIELDS).withReadOnlyReason("Use the connection actions to make changes.")
.withActionNames("REVOKED".equals(connection.getValueString("status")) ? Set.of() : rowActions));
}
UserPreferenceValues values = new UserPreferenceValues();
values.getLists().put("connections", rows);
return (values);
}
/*******************************************************************************
** Even an empty PATCH is rejected: this section has no editable settings.
*******************************************************************************/
@Override
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
throw (new QBadRequestException("Slack connections: can't be changed. Use the connection actions."));
}
/*******************************************************************************
** RC.7 has no redirect result; return the authorize URL in its message field.
** Row services retain ownership checks even after core has checked the row.
*******************************************************************************/
@Override
public UserPreferenceActionResult runAction(UserPreferenceRequestContext context, UserPreferenceActionRequest request) throws QException
{
String action = request.getActionName();
if(!permitted(action))
{
throw (new QBadRequestException("This action isn't available."));
}
if(SlackProcesses.CONNECT.equals(action) || SlackProcesses.INSTALL.equals(action))
{
return (new UserPreferenceActionResult().withMessage(SlackPreferences.connect(SlackProcesses.INSTALL.equals(action))));
}
Integer id;
try
{
id = Integer.valueOf(request.getRowKey());
}
catch(NumberFormatException e)
{
throw (new QBadRequestException("This action isn't available for this row."));
}
QRecord connection = SlackConnect.visible(SlackTables.CONNECTION, id);
if(!context.getUserId().equals(connection.getValueString("userId")))
{
throw (new QBadRequestException("This action isn't available for this row."));
}
if(SlackProcesses.DISCONNECT.equals(action))
{
SlackConnect.disconnect(id);
return (new UserPreferenceActionResult().withMessage("Slack disconnected."));
}
SlackSendResult result = SlackPreferences.testMessage(id);
if(result.status() == SlackSendResult.Status.REFUSED)
{
throw (new QBadRequestException(String.join("; ", result.reasons())));
}
return (new UserPreferenceActionResult().withMessage("Test message queued.").withFocusRowKey(request.getRowKey()));
}
}
@@ -0,0 +1,307 @@
/*
* 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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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.readiness.SlackReadiness;
import com.kingsrook.qbits.slack.template.SlackTemplates;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
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.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.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** The qbit's scheduled processes. Hidden: they are never in navigation. The
** dispatch job runs on the config's scheduler every dispatchIntervalSeconds.
*******************************************************************************/
public final class SlackProcesses
{
public static final String CONNECT = "slack_connect";
public static final String INSTALL = "slack_install";
public static final String TEST_MESSAGE = "slack_test_message";
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";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackProcesses()
{
}
/*******************************************************************************
** The processes, for the producer.
*******************************************************************************/
public static List<QProcessMetaData> processes(SlackQBitConfig config)
{
QProcessMetaData dispatch = new QProcessMetaData().withName(DISPATCH).withLabel("Dispatch Slack Messages").withIsHidden(true)
.withPermissionRules(SlackTables.permissionRules())
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(ScheduledStep.class)));
if(config.getSchedulerName() != null)
{
dispatch.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName())
.withRepeatSeconds(config.getDispatchIntervalSeconds()).withInitialDelaySeconds(Math.min(config.getDispatchIntervalSeconds(), 30)));
}
QProcessMetaData readiness = new QProcessMetaData().withName(MAINTAIN).withLabel("Check Slack Readiness").withIsHidden(true)
.withPermissionRules(SlackTables.permissionRules())
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(ReadinessStep.class)));
if(config.getSchedulerName() != null)
{
readiness.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName())
.withRepeatSeconds(config.getReadinessIntervalMinutes() * 60).withInitialDelaySeconds(1));
}
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(CHECK_CHANNELS, "Check channels", SlackTables.CHANNEL)));
}
/*******************************************************************************
** OAuth starts without requiring an existing connection or selected record.
*******************************************************************************/
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"))));
}
/*******************************************************************************
** Return the authorize URL through the standard process UI API.
*******************************************************************************/
public static class ConnectStep implements BackendStep
{
/*******************************************************************************
** No remote mutation happens until the user approves at Slack.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output)
{
output.addValue("authorizeUrl", com.kingsrook.qbits.slack.preferences.SlackPreferences.connect(INSTALL.equals(input.getProcessName())));
}
}
/*******************************************************************************
** The scheduler's entry point.
*******************************************************************************/
public static class ScheduledStep implements BackendStep
{
/*******************************************************************************
** Dispatch the due messages.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new SlackDispatcher().runOnce();
}
}
/*******************************************************************************
** Standard streamed ETL Review and Result, never a navigation entry.
*******************************************************************************/
private static QProcessMetaData action(String name, String label, String table)
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label + " for selected records.");
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, ActionTransform.class, ActionLoad.class, values)
.withName(name).withLabel(label).withTableName(table).withPermissionRules(SlackTables.permissionRules()));
}
/*******************************************************************************
** Readiness runs after storage startup; seeding preserves existing edits.
*******************************************************************************/
public static class ReadinessStep implements BackendStep
{
/*******************************************************************************
** Seed built-ins and refresh deployment and workspace snapshots.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
SlackTemplates.seed();
SlackReadiness.run();
}
}
/*******************************************************************************
** Review selected rows without remote calls or mutation.
*******************************************************************************/
public static class ActionTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Validate permissions before offering the action.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
var session = com.kingsrook.qqq.backend.core.context.QContext.getQSession();
for(QRecord row : input.getRecords())
{
boolean permitted = TEST_MESSAGE.equals(input.getProcessName())
? session.hasPermission("slack.send") && session.getUser().getIdReference().equals(row.getValueString("userId"))
: session.hasPermission("slack.admin") || DISCONNECT.equals(input.getProcessName())
&& session.hasPermission("slack.connect") && session.getUser().getIdReference().equals(row.getValueString("userId"));
lines.add(new ProcessSummaryLine(permitted ? Status.OK : Status.INFO, 1, permitted ? "Record will be processed" : "Record not permitted"));
if(permitted)
{
output.getRecords().add(row);
}
}
}
/*******************************************************************************
** Review summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
/*******************************************************************************
** Execute only after review, with fresh row authorization.
*******************************************************************************/
public static class ActionLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Service methods recheck current permissions and tenant visibility.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : input.getRecords())
{
if(CHECK_CHANNELS.equals(input.getProcessName()))
{
checkChannel(row, output);
continue;
}
switch(input.getProcessName())
{
case TEST_MESSAGE ->
{
var result = com.kingsrook.qbits.slack.preferences.SlackPreferences.testMessage(row.getValueInteger("id"));
if(result.status() != com.kingsrook.qbits.slack.outbox.SlackSendResult.Status.QUEUED)
{
throw (new QException("Slack test message could not be queued: " + String.join("; ", result.reasons())));
}
output.addValue("messageId", result.messageId());
}
case DISCONNECT -> SlackConnect.disconnect(row.getValueInteger("id"));
case UNINSTALL -> SlackConnect.uninstall(row.getValueInteger("id"));
case READINESS ->
{
if(!com.kingsrook.qqq.backend.core.context.QContext.getQSession().hasPermission("slack.admin"))
{
throw (new QException("Slack readiness action is not permitted"));
}
SlackReadiness.workspace(row.getValueInteger("id"));
}
default -> throw (new QException("Unknown Slack action"));
}
lines.add(new ProcessSummaryLine(Status.OK, 1, "Record processed"));
output.getRecords().add(row);
}
}
/*******************************************************************************
** 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.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
}
@@ -0,0 +1,92 @@
/*
* 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.readiness;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.health.HealthIndicator;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckMetaData;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
/*******************************************************************************
** Optional RC.6 health integration, loaded only when its module is present.
*******************************************************************************/
public class SlackHealth implements HealthIndicator
{
/*******************************************************************************
** Register once without replacing the host's existing health metadata.
*******************************************************************************/
public static void register(QInstance instance)
{
HealthCheckMetaData metadata = (HealthCheckMetaData) instance.getSupplementalMetaData(HealthCheckMetaData.METADATA_KEY);
if(metadata == null)
{
metadata = new HealthCheckMetaData();
metadata.addSelfToInstance(instance);
}
if(metadata.getIndicators().stream().noneMatch(i -> "slack".equals(i.getName())))
{
metadata.getIndicators().add(new SlackHealth());
}
}
/*******************************************************************************
** Health component name.
*******************************************************************************/
@Override
public String getName()
{
return ("slack");
}
/*******************************************************************************
** Snapshot only: tenant failures never make a pod unhealthy.
*******************************************************************************/
@Override
public HealthCheckResult check(QInstance instance)
{
var readiness = SlackReadiness.stored(instance);
String appStatus = readiness.checks().stream().filter(c -> "app".equals(c.name())).map(c -> c.status()).findFirst().orElse("UNKNOWN");
//////////////////////////////////////////////////////////////////////////////
// Slack is optional. Not configured (no secrets at all) or not yet checked //
// is UNKNOWN, which never fails a readiness probe (HTTP 200). Only a //
// configured-but-broken app (partial or invalid secrets) is DOWN. //
//////////////////////////////////////////////////////////////////////////////
if(SlackReadiness.NOT_CONFIGURED.equals(appStatus))
{
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN)
.withDetail("configured", false)
.withDetail("message", "Slack is not configured")
.withDetail("checks", readiness.checks()));
}
if("UNKNOWN".equals(appStatus))
{
return (new HealthCheckResult().withStatus(HealthStatus.UNKNOWN)
.withDetail("message", "Slack readiness has not run yet")
.withDetail("checks", readiness.checks()));
}
HealthStatus status = "ERROR".equals(appStatus) ? HealthStatus.DOWN : readiness.ready() ? HealthStatus.UP : HealthStatus.DEGRADED;
return (new HealthCheckResult().withStatus(status)
.withDetail("configured", true)
.withDetail("checks", readiness.checks()));
}
}
@@ -0,0 +1,372 @@
/*
* 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.readiness;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.api.SlackApiReadiness;
import com.kingsrook.qbits.slack.api.SlackApiReadinessCheck;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.oauth.SlackConnect;
import com.kingsrook.qbits.slack.security.SlackSecrets;
import com.kingsrook.qbits.slack.security.SlackTokens;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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;
/*******************************************************************************
** Fixed readiness checks and stored deployment snapshots. Health probes never
** call Slack. Tenant failures are details, not deployment health failures.
*******************************************************************************/
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<>());
/*******************************************************************************
** Last actual checks, scoped to this instance; never invent a successful check.
*******************************************************************************/
public static SlackApiReadiness workspaceSnapshot(Integer id)
{
return (WORKSPACES.getOrDefault(QContext.getQInstance(), Map.of()).getOrDefault(id,
new SlackApiReadiness(false, List.of(new SlackApiReadinessCheck("workspace:" + id, "UNKNOWN", "Checks have not run since startup"),
new SlackApiReadinessCheck("registeredChannels", "UNKNOWN", "Checks have not run since startup")))));
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackReadiness()
{
}
/*******************************************************************************
** Presence-only application check and current outbox condition.
*******************************************************************************/
public static SlackApiReadiness deployment(QInstance instance) throws QException
{
SlackQBitConfig config = SlackRuntime.config(instance);
boolean app = false;
try
{
SlackSecrets.get(config, config.getClientIdEnvName());
SlackSecrets.get(config, config.getClientSecretEnvName());
SlackSecrets.get(config, config.getSigningSecretEnvName());
app = Base64.getDecoder().decode(SlackSecrets.get(config, config.getTokenKeyEnvName())).length == 32;
}
catch(Exception e)
{
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)
{
for(QRecord row : SlackStore.query(SlackTables.MESSAGE, new QQueryFilter()))
{
java.time.Instant created = row.getValueInstant("createDate");
java.time.Instant dead = row.getValueInstant("deadLetterDate");
if("QUEUED".equals(row.getValueString("status")) && created != null && created.isBefore(SlackRuntime.clock().instant().minusSeconds(600))
|| "DEAD_LETTER".equals(row.getValueString("status")) && dead != null && dead.isAfter(SlackRuntime.clock().instant().minusSeconds(3600)))
{
outbox = false;
}
}
}
else
{
outbox = false;
}
checks.add(check("outbox", outbox, "Recent outbox progress"));
SlackApiReadiness result = result(checks);
STORED.put(instance, result);
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.
*******************************************************************************/
public static SlackApiReadiness stored(QInstance instance)
{
return (STORED.getOrDefault(instance, new SlackApiReadiness(false, List.of(new SlackApiReadinessCheck("app", "UNKNOWN", "Readiness has not run")))));
}
/*******************************************************************************
** Check a workspace already visible to this caller and store fixed results.
*******************************************************************************/
public static SlackApiReadiness workspace(Integer id) throws QException
{
QRecord workspace = SlackConnect.visible(SlackTables.WORKSPACE, id);
SlackQBitConfig config = SlackRuntime.config();
List<SlackApiReadinessCheck> checks = new ArrayList<>();
boolean good = false;
boolean channels = true;
boolean channelsUnknown = false;
boolean optionalScopes = false;
try
{
String token = SlackTokens.botToken(config, id);
var response = SlackRuntime.client(config).authTest(token);
good = "ACTIVE".equals(workspace.getValueString("status")) && response.isOk()
&& workspace.getValueString("teamId").equals(response.getString("team_id"))
&& response.getScopes() != null && List.of(response.getScopes().split(",")).containsAll(List.of(SlackConnect.BOT_SCOPES.split(",")));
optionalScopes = response.getScopes() != null && List.of(response.getScopes().split(",")).containsAll(List.of("usergroups:read", "chat:write.customize"));
for(QRecord channel : SlackStore.queryAsCurrent(SlackTables.CHANNEL, SlackStore.equal("workspaceId", id)))
{
if(Boolean.TRUE.equals(channel.getValueBoolean("enabled")))
{
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)
{
good = false;
channels = false;
}
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(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")
.withValue("readinessCheckedAt", SlackRuntime.clock().instant()).withValue("readinessSummary", result.ready() ? "Workspace ready" : "Workspace needs attention"), null);
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.
*******************************************************************************/
public static SlackApiReadinessCheck connection(Integer id) throws QException
{
QRecord row = SlackConnect.visible(SlackTables.CONNECTION, id);
boolean good = false;
try
{
SlackQBitConfig config = SlackRuntime.config();
QRecord workspace = SlackConnect.visible(SlackTables.WORKSPACE, row.getValueInteger("workspaceId"));
var response = SlackRuntime.client(config).authTest(SlackTokens.userToken(config, id));
good = "ACTIVE".equals(row.getValueString("status")) && response.isOk()
&& workspace.getValueString("teamId").equals(response.getString("team_id"))
&& row.getValueString("slackUserId").equals(response.getString("user_id"))
&& response.getScopes() != null && List.of(response.getScopes().split(",")).containsAll(List.of(SlackConnect.USER_SCOPES.split(",")));
}
catch(Exception e)
{
good = false;
}
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", id).withValue("lastVerifiedAt", SlackRuntime.clock().instant()), null);
return (check("connection:" + id, good, "Connection identity and required scopes"));
}
/*******************************************************************************
** Scheduled maintenance runs as the system across all workspace rows.
*******************************************************************************/
public static void run() throws QException
{
deployment(QContext.getQInstance());
for(QRecord workspace : SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new QQueryFilter()))
{
workspace(workspace.getValueInteger("id"));
}
for(QRecord connection : SlackStore.queryAsCurrent(SlackTables.CONNECTION, new QQueryFilter()))
{
java.time.Instant checked = connection.getValueInstant("lastVerifiedAt");
if("ACTIVE".equals(connection.getValueString("status")) && (checked == null || checked.isBefore(SlackRuntime.clock().instant().minusSeconds(86400))))
{
connection(connection.getValueInteger("id"));
}
}
}
/*******************************************************************************
** Fixed diagnostics only.
*******************************************************************************/
private static SlackApiReadinessCheck check(String name, boolean ready, String summary)
{
return (new SlackApiReadinessCheck(name, ready ? "READY" : "ERROR", summary));
}
/*******************************************************************************
** Aggregate without discarding individual checks.
*******************************************************************************/
public static SlackApiReadiness result(List<SlackApiReadinessCheck> checks)
{
return (new SlackApiReadiness(checks.stream().allMatch(c -> "READY".equals(c.status()) || "WARN".equals(c.status())), checks));
}
}
@@ -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.security;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
/*******************************************************************************
** The result of a successful authorization (docs/DESIGN.md section 4.1): the
** one workspace row and (for an owned send) the one connection row whose token
** may be used, the posting identity, and the resolved target. It holds ids
** only, never a token.
**
** slackChannelId is null for a direct message until the dispatcher opens it.
** connectionId and slackUserId are null for an ownerless registered-channel
** send. mentionPrefix is the user-group mention to put before the text.
*******************************************************************************/
public record AuthorizedSend(
Integer workspaceId,
Integer connectionId,
SlackEnums.PostAs postAs,
String slackChannelId,
String channelLabel,
SlackEnums.TargetType targetType,
String targetRef,
String slackUserId,
String mentionPrefix,
String tenantKey)
{
}
@@ -0,0 +1,122 @@
/*
* 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.security;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import org.json.JSONObject;
/*******************************************************************************
** Stateless, domain-separated button capabilities bound to one linked message.
*******************************************************************************/
public final class SlackActionToken
{
/*******************************************************************************
** Authenticated claims; expiry is checked by the interaction handler.
*******************************************************************************/
public record Claims(int messageId, String actionName, String tableName, String recordId, long expiry)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackActionToken()
{
}
/*******************************************************************************
** Default seven-day lifetime.
*******************************************************************************/
public static String issue(SlackQBitConfig config, int messageId, String action, String table, String record)
{
String payload = encode(new JSONObject().put("message", messageId).put("action", action).put("table", table)
.put("record", record).put("expiry", SlackRuntime.clock().instant().getEpochSecond() + 604800).toString().getBytes(StandardCharsets.UTF_8));
String token = payload + "." + encode(sign(config, payload));
if(token.length() > 2000)
{
throw (new IllegalArgumentException("Slack button value exceeds limit"));
}
return (token);
}
/*******************************************************************************
** Verify before decoding claims. Never include token contents in failures.
*******************************************************************************/
public static Claims verify(SlackQBitConfig config, String token)
{
try
{
if(token == null || token.length() > 2000)
{
throw (new IllegalArgumentException());
}
String[] parts = token.split("\\.", -1);
if(parts.length != 2 || !MessageDigest.isEqual(sign(config, parts[0]), Base64.getUrlDecoder().decode(parts[1])))
{
throw (new IllegalArgumentException());
}
JSONObject json = new JSONObject(new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8));
return (new Claims(json.getInt("message"), json.getString("action"), json.getString("table"), json.getString("record"), json.getLong("expiry")));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Invalid Slack button"));
}
}
/*******************************************************************************
** Derive a purpose-specific signing key from the configured token key.
*******************************************************************************/
private static byte[] sign(SlackQBitConfig config, String payload)
{
try
{
byte[] key = Base64.getDecoder().decode(SlackSecrets.get(config, config.getTokenKeyEnvName()));
if(key.length != 32)
{
throw (new IllegalArgumentException());
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
byte[] derived = mac.doFinal("slack-action".getBytes(StandardCharsets.UTF_8));
mac.init(new SecretKeySpec(derived, "HmacSHA256"));
return (mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
}
catch(Exception e)
{
throw (new IllegalArgumentException("Slack signing unavailable"));
}
}
/*******************************************************************************
** URL-safe unpadded encoding.
*******************************************************************************/
private static String encode(byte[] bytes)
{
return (Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
}
}
@@ -0,0 +1,102 @@
/*
* 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.security;
/*******************************************************************************
** Either an AuthorizedSend or a SlackRefusal, never both.
*******************************************************************************/
public class SlackAuthorization
{
private final AuthorizedSend send;
private final SlackRefusal refusal;
/*******************************************************************************
** Constructor.
*******************************************************************************/
private SlackAuthorization(AuthorizedSend send, SlackRefusal refusal)
{
this.send = send;
this.refusal = refusal;
}
/*******************************************************************************
** The send was authorized.
*******************************************************************************/
public static SlackAuthorization allow(AuthorizedSend send)
{
return (new SlackAuthorization(send, null));
}
/*******************************************************************************
** The send was refused.
*******************************************************************************/
public static SlackAuthorization refuse(SlackRefusal refusal)
{
return (new SlackAuthorization(null, refusal));
}
/*******************************************************************************
** The send was refused, without a call to Slack being involved.
*******************************************************************************/
public static SlackAuthorization refuse(SlackRefusal.Reason reason, String message)
{
return (refuse(SlackRefusal.of(reason, message)));
}
/*******************************************************************************
** True when the send was authorized.
*******************************************************************************/
public boolean isAuthorized()
{
return (send != null);
}
/*******************************************************************************
** The authorized send, or null when refused.
*******************************************************************************/
public AuthorizedSend getSend()
{
return (send);
}
/*******************************************************************************
** The refusal, or null when authorized.
*******************************************************************************/
public SlackRefusal getRefusal()
{
return (refusal);
}
}
@@ -0,0 +1,782 @@
/*
* 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.security;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.metadata.SlackEnums;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.support.SlackMembershipCache;
import com.kingsrook.qbits.slack.support.SlackRuntime;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qbits.slack.transport.SlackClient;
import com.kingsrook.qbits.slack.transport.SlackErrorClassifier;
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.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
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.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Decides whether a QQQ user (the owner) may post to a target, and with which
** token (docs/DESIGN.md section 4.1, requirements R1 to R4). The same code runs
** when a message is queued and again when it is sent.
**
** The order is the security argument:
** 1. Every lookup of workspace, connection, registered channel and user group
** rows runs in the owner's session (SlackOwnerSession), so the host's tenant
** lock and the owner lock apply on their own. A row of another tenant or
** another user is invisible, never merely filtered afterwards.
** 2. Only then is one chosen row's token read (SlackTokens, a system read by
** primary key), to ask Slack who is in the channel.
** 3. The owner's Slack user id must be in conversations.members. The answer is
** cached per workspace row and channel for membershipCacheMinutes.
**
** There is never a fallback to another connection, user or tenant.
**
** An ownerless send (a null owner, or "system:<tenant>" on a stored row) is
** allowed only to a registered channel, only from a system session, and only
** with a tenant key equal to the workspace's, and always posts as the bot.
*******************************************************************************/
public class SlackAuthorizer
{
public static final String SYSTEM_OWNER_PREFIX = "system:";
private static final int MAX_MEMBER_PAGES = 100;
private static final Pattern CHANNEL_ID = Pattern.compile("[A-Za-z0-9]{1,50}");
private static final String ADDRESS_HELP = "That Slack address isn't recognized. Use me, channel:<workspace>/<channel>, channel:<name>, #<name> or group:<name>.";
/*******************************************************************************
** What to authorize: the owner (null for none), the tenant key an ownerless
** send claims, the address (docs/DESIGN.md 4.2), an optional workspace to
** pin, and an optional posting identity to insist on.
*******************************************************************************/
public static class Request
{
private String ownerUserId;
private String tenantKey;
private String address;
private Integer workspaceId;
private SlackEnums.PostAs postAs;
/*******************************************************************************
** Fluent setter for ownerUserId
*******************************************************************************/
public Request withOwnerUserId(String ownerUserId)
{
this.ownerUserId = ownerUserId;
return (this);
}
/*******************************************************************************
** Fluent setter for tenantKey
*******************************************************************************/
public Request withTenantKey(String tenantKey)
{
this.tenantKey = tenantKey;
return (this);
}
/*******************************************************************************
** Fluent setter for address
*******************************************************************************/
public Request withAddress(String address)
{
this.address = address;
return (this);
}
/*******************************************************************************
** Fluent setter for workspaceId
*******************************************************************************/
public Request withWorkspaceId(Integer workspaceId)
{
this.workspaceId = workspaceId;
return (this);
}
/*******************************************************************************
** Fluent setter for postAs
*******************************************************************************/
public Request withPostAs(SlackEnums.PostAs postAs)
{
this.postAs = postAs;
return (this);
}
}
/*******************************************************************************
** A parsed address.
*******************************************************************************/
private record Target(SlackEnums.TargetType type, String ref, Integer workspaceId, String channelId, String name)
{
}
/*******************************************************************************
** A workspace and the owner's connection in it, or why there isn't one.
*******************************************************************************/
private record Pick(QRecord workspace, QRecord connection, SlackAuthorization refusal)
{
}
/*******************************************************************************
** Who is in a channel, or why that couldn't be found out.
*******************************************************************************/
private record Lookup(SlackMembershipCache.Entry entry, SlackAuthorization refusal)
{
}
/*******************************************************************************
** The owner value stored on a message that has no user owner.
*******************************************************************************/
public static String systemOwner(String tenantKey)
{
return (SYSTEM_OWNER_PREFIX + (tenantKey == null ? "" : tenantKey));
}
/*******************************************************************************
** True for a null owner or a stored ownerless value.
*******************************************************************************/
public static boolean isOwnerless(String ownerUserId)
{
return (ownerUserId == null || ownerUserId.startsWith(SYSTEM_OWNER_PREFIX));
}
/*******************************************************************************
** The tenant key inside a stored ownerless value, or null.
*******************************************************************************/
public static String tenantKeyOf(String ownerUserId)
{
if(ownerUserId == null || !ownerUserId.startsWith(SYSTEM_OWNER_PREFIX) || ownerUserId.length() == SYSTEM_OWNER_PREFIX.length())
{
return (null);
}
return (ownerUserId.substring(SYSTEM_OWNER_PREFIX.length()));
}
/*******************************************************************************
** Authorize one send. A refusal carries a reason fit for the user.
*******************************************************************************/
public SlackAuthorization authorize(Request request) throws QException
{
SlackQBitConfig config = SlackRuntime.config();
if(config == null)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "The Slack qbit isn't installed"));
}
Target target = parse(request.address);
if(target == null)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, ADDRESS_HELP));
}
if(isOwnerless(request.ownerUserId))
{
return (authorizeOwnerless(config, request, target));
}
QSession session;
try
{
session = SlackOwnerSession.create(QContext.getQInstance(), request.ownerUserId);
}
catch(QException e)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "Slack couldn't verify your access"));
}
try
{
return (SlackOwnerSession.run(session, () -> authorizeAsOwner(config, request, target)));
}
catch(QPermissionDeniedException e)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "You don't have access to Slack"));
}
}
/*******************************************************************************
** Parse an address into its target, or null when it isn't one of the forms.
*******************************************************************************/
private static Target parse(String address)
{
if(address == null || address.isBlank())
{
return (null);
}
String a = address.trim();
if(a.equals("me"))
{
return (new Target(SlackEnums.TargetType.ME, a, null, null, null));
}
if(a.startsWith("user:") && a.length() > "user:".length())
{
return (new Target(SlackEnums.TargetType.USER, a, null, null, a.substring("user:".length())));
}
if(a.startsWith("group:") && a.length() > "group:".length())
{
return (new Target(SlackEnums.TargetType.USER_GROUP, a, null, null, a.substring("group:".length())));
}
if(a.startsWith("#") && a.length() > 1)
{
return (new Target(SlackEnums.TargetType.REGISTERED_CHANNEL, a, null, null, a.substring(1)));
}
if(a.startsWith("channel:") && a.length() > "channel:".length())
{
String rest = a.substring("channel:".length());
int slash = rest.indexOf('/');
if(slash < 0)
{
return (new Target(SlackEnums.TargetType.REGISTERED_CHANNEL, a, null, null, rest));
}
try
{
Integer workspaceId = Integer.valueOf(rest.substring(0, slash));
String channelId = rest.substring(slash + 1);
return (CHANNEL_ID.matcher(channelId).matches() ? new Target(SlackEnums.TargetType.CHANNEL, a, workspaceId, channelId, null) : null);
}
catch(NumberFormatException e)
{
return (null);
}
}
return (null);
}
/*******************************************************************************
** All the owner-session lookups and the membership check. Runs inside the
** owner's session.
*******************************************************************************/
private SlackAuthorization authorizeAsOwner(SlackQBitConfig config, Request request, Target target) throws QException
{
String owner = request.ownerUserId;
QRecord workspace;
QRecord connection;
String channelId = null;
String channelLabel = null;
String mention = null;
if(target.type == SlackEnums.TargetType.ME || target.type == SlackEnums.TargetType.USER)
{
if(target.type == SlackEnums.TargetType.USER && !owner.equals(target.name))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "You can only send Slack direct messages to yourself"));
}
Pick pick = pick(owner, request.workspaceId);
if(pick.refusal != null)
{
return (pick.refusal);
}
workspace = pick.workspace;
connection = pick.connection;
}
else if(target.type == SlackEnums.TargetType.CHANNEL)
{
if(request.workspaceId != null && !request.workspaceId.equals(target.workspaceId))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, "That channel isn't in the workspace this message is for"));
}
Pick pick = pick(owner, target.workspaceId);
if(pick.refusal != null)
{
return (pick.refusal);
}
workspace = pick.workspace;
connection = pick.connection;
channelId = target.channelId;
}
else
{
boolean group = target.type == SlackEnums.TargetType.USER_GROUP;
String table = group ? SlackTables.USER_GROUP : SlackTables.CHANNEL;
QQueryFilter filter = SlackStore.equal("name", target.name).withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true));
if(request.workspaceId != null)
{
filter.withCriteria(new QFilterCriteria("workspaceId", QCriteriaOperator.EQUALS, request.workspaceId));
}
///////////////////////////////////////////////////////////////////////////
// these rows are tenant-locked through their workspace: another tenant's //
// channel or group of the same name is invisible to this owner //
///////////////////////////////////////////////////////////////////////////
List<QRecord> rows = SlackStore.queryAsCurrent(table, filter);
List<Pick> picks = new ArrayList<>();
List<QRecord> picked = new ArrayList<>();
for(QRecord row : rows)
{
Pick candidate = pick(owner, row.getValueInteger("workspaceId"));
if(candidate.refusal == null)
{
picks.add(candidate);
picked.add(row);
}
}
if(picks.isEmpty())
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't a registered Slack " + (group ? "user group" : "channel") + " available to you"));
}
if(picks.size() > 1)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, target.name + " is registered in more than one of your workspaces. Name the workspace."));
}
workspace = picks.get(0).workspace;
connection = picks.get(0).connection;
QRecord row = picked.get(0);
channelId = group ? row.getValueString("channelId") : row.getValueString("slackChannelId");
channelLabel = "#" + (group || row.getValueString("slackChannelName") == null ? target.name : row.getValueString("slackChannelName"));
if(channelId == null || channelId.isBlank())
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't linked to a Slack channel yet"));
}
if(group)
{
if(row.getValueString("slackUsergroupId") == null)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't linked to a Slack user group yet"));
}
String handle = row.getValueString("handle");
mention = "<!subteam^" + row.getValueString("slackUsergroupId") + (handle != null && handle.matches("@?[A-Za-z0-9._-]+") ? "|@" + handle.replaceFirst("^@", "") : "") + "> ";
}
}
String slackUserId = connection.getValueString("slackUserId");
boolean hasBot = notBlank(workspace.getValueString("botTokenFingerprint"));
boolean hasUser = notBlank(connection.getValueString("userTokenFingerprint"));
if(slackUserId == null || slackUserId.isBlank())
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_CONNECTION, "Your Slack connection isn't complete. Reconnect Slack."));
}
Integer workspaceId = workspace.getValueInteger("id");
Integer connectionId = connection.getValueInteger("id");
String tenantKey = workspace.getValueString("tenantKey");
///////////////////////////////////////////////////////////////////////
// direct messages: no channel to check, and the dispatcher opens it //
///////////////////////////////////////////////////////////////////////
if(channelId == null)
{
SlackEnums.PostAs identity = request.postAs != null ? request.postAs : hasBot ? SlackEnums.PostAs.BOT : SlackEnums.PostAs.USER;
if(identity == SlackEnums.PostAs.BOT ? !hasBot : !hasUser)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "No Slack token is available to send as that identity. Reconnect Slack."));
}
return (SlackAuthorization.allow(new AuthorizedSend(workspaceId, connectionId, identity, null, "Direct message", target.type, target.ref, slackUserId, null, tenantKey)));
}
Lookup lookup = lookupMembership(config, workspace, connection, channelId, request.postAs, hasBot, hasUser);
if(lookup.refusal != null)
{
return (lookup.refusal);
}
SlackMembershipCache.Entry entry = lookup.entry;
if(channelLabel == null)
{
channelLabel = entry.channelName() == null ? channelId : "#" + entry.channelName();
}
if(!entry.members().contains(slackUserId))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_MEMBER, "You aren't a member of " + channelLabel));
}
SlackEnums.PostAs identity = request.postAs;
if(identity == null)
{
identity = entry.botIsMember() && hasBot ? SlackEnums.PostAs.BOT : hasUser ? SlackEnums.PostAs.USER : null;
}
if(identity == SlackEnums.PostAs.BOT && !(entry.botIsMember() && hasBot))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "The Slack bot isn't in " + channelLabel + ". Invite it to the channel."));
}
if(identity == null || (identity == SlackEnums.PostAs.USER && !hasUser))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "The Slack app isn't in " + channelLabel + " and you have no personal Slack token. Reconnect Slack."));
}
return (SlackAuthorization.allow(new AuthorizedSend(workspaceId, connectionId, identity, channelId, channelLabel, target.type, target.ref, slackUserId, mention, tenantKey)));
}
/*******************************************************************************
** Find the owner's ACTIVE connection and its ACTIVE workspace, in the owner's
** session (so only the owner's tenant's workspaces and the owner's own
** connections can match). More than one match needs a named workspace.
*******************************************************************************/
private Pick pick(String owner, Integer workspaceId) throws QException
{
QQueryFilter filter = SlackStore.equal("userId", owner).withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, SlackEnums.ConnectionStatus.ACTIVE.name()));
if(workspaceId != null)
{
filter.withCriteria(new QFilterCriteria("workspaceId", QCriteriaOperator.EQUALS, workspaceId));
}
List<QRecord> connections = SlackStore.queryAsCurrent(SlackTables.CONNECTION, filter);
if(connections.isEmpty())
{
return (new Pick(null, null, SlackAuthorization.refuse(SlackRefusal.Reason.NO_CONNECTION, "Connect Slack first")));
}
List<Serializable> ids = new ArrayList<>();
connections.forEach(c -> ids.add(c.getValueInteger("workspaceId")));
List<QRecord> workspaces = SlackStore.queryAsCurrent(SlackTables.WORKSPACE, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids))
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, SlackEnums.WorkspaceStatus.ACTIVE.name())));
QRecord foundWorkspace = null;
QRecord foundConnection = null;
int matches = 0;
for(QRecord connection : connections)
{
for(QRecord workspace : workspaces)
{
if(workspace.getValueInteger("id").equals(connection.getValueInteger("workspaceId")))
{
foundWorkspace = workspace;
foundConnection = connection;
matches++;
}
}
}
if(matches == 0)
{
return (new Pick(null, null, SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, "This Slack workspace isn't available to you")));
}
if(matches > 1)
{
return (new Pick(null, null, SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, "You are connected to more than one Slack workspace. Name one.")));
}
return (new Pick(foundWorkspace, foundConnection, null));
}
/*******************************************************************************
** Who is in the channel: from the cache, or from conversations.members with
** the chosen rows' tokens (the bot's first, unless the caller asked for the
** owner's own identity). A bot that isn't in the channel falls back to the
** owner's token; no other token is ever tried.
*******************************************************************************/
private Lookup lookupMembership(SlackQBitConfig config, QRecord workspace, QRecord connection, String channelId, SlackEnums.PostAs requested, boolean hasBot, boolean hasUser) throws QException
{
Integer workspaceId = workspace.getValueInteger("id");
Integer connectionId = connection.getValueInteger("id");
Instant now = SlackRuntime.clock().instant();
SlackMembershipCache.Entry cached = SlackRuntime.membership().get(workspaceId, channelId, now);
if(cached != null)
{
return (new Lookup(cached, null));
}
List<SlackEnums.PostAs> order = new ArrayList<>();
if(requested != null)
{
order.add(requested);
}
else
{
if(hasBot)
{
order.add(SlackEnums.PostAs.BOT);
}
if(hasUser)
{
order.add(SlackEnums.PostAs.USER);
}
}
SlackAuthorization last = SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "No Slack token is available. Reconnect Slack.");
SlackClient client = SlackRuntime.client(config);
for(SlackEnums.PostAs identity : order)
{
if(identity == SlackEnums.PostAs.BOT ? !hasBot : !hasUser)
{
continue;
}
String token;
try
{
token = identity == SlackEnums.PostAs.BOT ? SlackTokens.botToken(config, workspaceId) : SlackTokens.userToken(config, connectionId);
}
catch(QException e)
{
last = SlackAuthorization.refuse(new SlackRefusal(SlackRefusal.Reason.AUTH, "Your Slack connection can't be used. Reconnect Slack.", SlackErrorClassifier.Kind.AUTH, "token_unreadable", null));
continue;
}
Set<String> members = new LinkedHashSet<>();
SlackResponse failure = readMembers(client, token, channelId, members);
if(failure == null)
{
////////////////////////////////////////////////////////////////////////
// the member list is the channel's, whichever token read it. without //
// the bot's user id, only a read by the bot itself says it is in it //
////////////////////////////////////////////////////////////////////////
String botUserId = workspace.getValueString("botUserId");
boolean botIsMember = botUserId == null ? identity == SlackEnums.PostAs.BOT : members.contains(botUserId);
SlackMembershipCache.Entry entry = new SlackMembershipCache.Entry(Set.copyOf(members), botIsMember, channelName(client, token, channelId), now.plusSeconds(60L * config.getMembershipCacheMinutes()));
SlackRuntime.membership().put(workspaceId, channelId, entry, now);
return (new Lookup(entry, null));
}
SlackErrorClassifier.Kind kind = SlackErrorClassifier.classify(failure);
last = SlackAuthorization.refuse(refusalFor(failure, kind));
if(SlackErrorClassifier.revokesAccess(failure.getError()))
{
markRevoked(identity, workspaceId, connectionId);
}
if(!"not_in_channel".equals(failure.getError()))
{
break;
}
}
return (new Lookup(null, last));
}
/*******************************************************************************
** Read every page of conversations.members into the set. Returns the first
** failing response, or null when all pages were read.
*******************************************************************************/
private static SlackResponse readMembers(SlackClient client, String token, String channelId, Set<String> members)
{
String cursor = null;
for(int page = 0; page < MAX_MEMBER_PAGES; page++)
{
SlackResponse response = client.conversationsMembers(token, channelId, cursor);
if(!response.isOk())
{
return (response);
}
JSONArray array = response.getJson().optJSONArray("members");
for(int i = 0; array != null && i < array.length(); i++)
{
members.add(array.optString(i));
}
JSONObject metadata = response.getJson().optJSONObject("response_metadata");
cursor = metadata == null ? null : metadata.optString("next_cursor", null);
if(cursor == null || cursor.isEmpty())
{
return (null);
}
}
return (null);
}
/*******************************************************************************
** The channel's name from conversations.info, or null when Slack won't say
** (the label then falls back to the channel id).
*******************************************************************************/
private static String channelName(SlackClient client, String token, String channelId)
{
SlackResponse response = client.conversationsInfo(token, channelId);
if(!response.isOk() || response.getJson().optJSONObject("channel") == null)
{
return (null);
}
return (response.getJson().getJSONObject("channel").optString("name", null));
}
/*******************************************************************************
** A user-facing refusal for a failed Slack call.
*******************************************************************************/
private static SlackRefusal refusalFor(SlackResponse failure, SlackErrorClassifier.Kind kind)
{
String code = failure.getError();
if(kind == SlackErrorClassifier.Kind.AUTH)
{
String message = SlackErrorClassifier.revokesAccess(code) ? "Slack access was revoked. Reconnect Slack." : "The Slack app can't see that channel (" + code + ")";
return (new SlackRefusal(SlackRefusal.Reason.AUTH, message, kind, code, null));
}
if(kind == SlackErrorClassifier.Kind.RATE_LIMITED)
{
return (new SlackRefusal(SlackRefusal.Reason.SLACK_ERROR, "Slack is limiting requests right now. Try again shortly.", kind, code, failure.getRetryAfterSeconds()));
}
if(kind == SlackErrorClassifier.Kind.TRANSIENT)
{
return (new SlackRefusal(SlackRefusal.Reason.SLACK_ERROR, "Slack isn't reachable right now. Try again shortly.", kind, code, null));
}
return (new SlackRefusal(SlackRefusal.Reason.NOT_FOUND, "That Slack channel wasn't found (" + code + ")", kind, code, null));
}
/*******************************************************************************
** An ownerless send: system session only, registered channel only, the
** workspace's own tenant, and the bot. When the host declares no tenant lock
** there is no tenant key to match.
*******************************************************************************/
private SlackAuthorization authorizeOwnerless(SlackQBitConfig config, Request request, Target target) throws QException
{
if(target.type != SlackEnums.TargetType.REGISTERED_CHANNEL)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "A Slack message without an owner can only go to a registered channel"));
}
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "Only a system session may send a Slack message without an owner"));
}
boolean tenantAware = !config.getRecordSecurityLocks().isEmpty();
String tenantKey = request.tenantKey != null ? request.tenantKey : tenantKeyOf(request.ownerUserId);
if(tenantAware && !notBlank(tenantKey))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NO_ACCESS, "A tenant is required for a Slack message without an owner"));
}
QQueryFilter workspaceFilter = SlackStore.equal("status", SlackEnums.WorkspaceStatus.ACTIVE.name());
if(tenantAware)
{
workspaceFilter.withCriteria(new QFilterCriteria("tenantKey", QCriteriaOperator.EQUALS, tenantKey));
}
if(request.workspaceId != null)
{
workspaceFilter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.EQUALS, request.workspaceId));
}
List<QRecord> workspaces = SlackStore.query(SlackTables.WORKSPACE, workspaceFilter);
List<Serializable> ids = new ArrayList<>();
workspaces.forEach(w -> ids.add(w.getValueInteger("id")));
if(ids.isEmpty())
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't a registered Slack channel for that tenant"));
}
List<QRecord> channels = SlackStore.query(SlackTables.CHANNEL, SlackStore.equal("name", target.name)
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)).withCriteria(new QFilterCriteria("workspaceId", QCriteriaOperator.IN, ids)));
if(channels.isEmpty())
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't a registered Slack channel for that tenant"));
}
if(channels.size() > 1)
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.BAD_ADDRESS, target.name + " is registered in more than one workspace"));
}
QRecord channel = channels.get(0);
QRecord workspace = workspaces.stream().filter(w -> w.getValueInteger("id").equals(channel.getValueInteger("workspaceId"))).findFirst().orElseThrow();
if(!notBlank(workspace.getValueString("botTokenFingerprint")))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.POSTING_IDENTITY, "A registered channel needs the workspace's Slack bot"));
}
if(!notBlank(channel.getValueString("slackChannelId")))
{
return (SlackAuthorization.refuse(SlackRefusal.Reason.NOT_FOUND, target.name + " isn't linked to a Slack channel yet"));
}
String name = channel.getValueString("slackChannelName") == null ? target.name : channel.getValueString("slackChannelName");
return (SlackAuthorization.allow(new AuthorizedSend(workspace.getValueInteger("id"), null, SlackEnums.PostAs.BOT, channel.getValueString("slackChannelId"), "#" + name,
SlackEnums.TargetType.REGISTERED_CHANNEL, target.ref, null, null, workspace.getValueString("tenantKey"))));
}
/*******************************************************************************
** Mark the row whose token Slack says is no longer valid as REVOKED: the
** workspace for the bot, the owner's connection for a user token.
*******************************************************************************/
public static void markRevoked(SlackEnums.PostAs identity, Integer workspaceId, Integer connectionId) throws QException
{
Instant now = SlackRuntime.clock().instant();
if(identity == SlackEnums.PostAs.BOT)
{
SlackStore.update(SlackTables.WORKSPACE, new QRecord().withValue("id", workspaceId).withValue("status", SlackEnums.WorkspaceStatus.REVOKED.name()), null);
}
else if(connectionId != null)
{
SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", connectionId).withValue("status", SlackEnums.ConnectionStatus.REVOKED.name())
.withValue("revokedAt", now).withValue("lastError", "Slack access was revoked"), null);
}
}
/*******************************************************************************
** True for a string with text in it.
*******************************************************************************/
private static boolean notBlank(String value)
{
return (value != null && !value.isBlank());
}
}
@@ -0,0 +1,113 @@
/*
* 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.security;
import java.util.Objects;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
/*******************************************************************************
** Runs work as a QQQ user, the way core's scheduled reports do
** (RunScheduledReportExecuteStep.updateSessionForUser): the session comes from
** the instance's authentication module through createAutomatedSessionForUser,
** so the host's customizer puts the user's permissions and tenant key on it.
** The session must name that user and be valid, and is never a system session.
**
** The slack_user key is then set to the owner (and any all-access key removed),
** so the owner locks on connections and messages match this user and only this
** user, whatever the host's customizer did. Queries run inside the work see
** the tenant lock, the owner lock and the table permissions of that user.
*******************************************************************************/
public final class SlackOwnerSession
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackOwnerSession()
{
}
/*******************************************************************************
** Build the owner's session.
*******************************************************************************/
public static QSession create(QInstance qInstance, String ownerUserId) throws QException
{
try
{
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
QSession session = module.createAutomatedSessionForUser(qInstance, ownerUserId);
if(session == null || session instanceof QSystemUserSession || session.getUser() == null
|| !Objects.equals(ownerUserId, session.getUser().getIdReference()) || !module.isSessionValid(qInstance, session))
{
throw (new QPermissionDeniedException("Could not establish the Slack owner's session."));
}
SlackSecurity.applyToSession(session, ownerUserId, false);
return (session);
}
catch(QException e)
{
throw (e);
}
catch(Exception e)
{
throw (new QException("Could not establish the Slack owner's session."));
}
}
/*******************************************************************************
** Run work in the owner's session, then restore the caller's context. The
** caller's transaction is not visible to the work.
*******************************************************************************/
public static <T> T call(String ownerUserId, SlackStore.Work<T> work) throws QException
{
return (run(create(QContext.getQInstance(), ownerUserId), work));
}
/*******************************************************************************
** Run work in an owner session that was already built (so a caller can tell a
** session that couldn't be built from work that failed), then restore the
** caller's context.
*******************************************************************************/
@SuppressWarnings("unchecked")
public static <T> T run(QSession session, SlackStore.Work<T> work) throws QException
{
Object[] holder = new Object[1];
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session), () -> holder[0] = work.run());
return ((T) holder[0]);
}
}
@@ -0,0 +1,77 @@
/*
* 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.security;
import java.util.regex.Pattern;
/*******************************************************************************
** Scrubs secrets out of any text that may be stored or shown (lastError,
** exception text, log lines): Slack tokens, bearer credentials, authorization
** headers and OAuth client_secret and code values. The result is capped, so an
** error body can't flood a column.
*******************************************************************************/
public final class SlackRedaction
{
public static final int MAX_LENGTH = 500;
public static final String REDACTED = "[REDACTED]";
private static final Pattern SLACK_TOKEN = Pattern.compile("xox[abeprs]-[A-Za-z0-9-]+");
private static final Pattern AUTH_HEADER = Pattern.compile("(?i)\\bauthorization\\s*[:=]\\s*(?:bearer\\s+|basic\\s+)?[^\\s\"',;]+");
private static final Pattern BEARER = Pattern.compile("(?i)\\bbearer\\s+[^\\s\"',;]+");
private static final Pattern QUERY_SECRETS = Pattern.compile("(?i)\\b(client_secret|code)=[^&\\s\"']*");
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackRedaction()
{
}
/*******************************************************************************
** Remove token and secret material from the text, then cap its length.
** Null stays null.
*******************************************************************************/
public static String scrub(String text)
{
if(text == null)
{
return (null);
}
String result = SLACK_TOKEN.matcher(text).replaceAll(REDACTED);
result = AUTH_HEADER.matcher(result).replaceAll("Authorization: " + REDACTED);
result = BEARER.matcher(result).replaceAll("Bearer " + REDACTED);
result = QUERY_SECRETS.matcher(result).replaceAll("$1=" + REDACTED);
if(result.length() > MAX_LENGTH)
{
result = result.substring(0, MAX_LENGTH);
}
return (result);
}
}
@@ -0,0 +1,60 @@
/*
* 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.security;
import com.kingsrook.qbits.slack.transport.SlackErrorClassifier;
/*******************************************************************************
** Why an authorization was refused. The message is for the user and contains
** no token or Slack payload. reason says what kind of refusal it is; when a
** call to Slack caused it (membership lookups), slackKind, slackError and
** retryAfterSeconds carry how Slack's answer was classified, so the dispatcher
** can retry a Slack outage instead of failing the owner's authorization.
*******************************************************************************/
public record SlackRefusal(Reason reason, String message, SlackErrorClassifier.Kind slackKind, String slackError, Integer retryAfterSeconds)
{
/*******************************************************************************
** The kinds of refusal.
*******************************************************************************/
public enum Reason
{
BAD_ADDRESS,
NOT_FOUND,
NO_CONNECTION,
NO_ACCESS,
NOT_MEMBER,
POSTING_IDENTITY,
AUTH,
SLACK_ERROR
}
/*******************************************************************************
** A refusal that didn't involve a call to Slack.
*******************************************************************************/
public static SlackRefusal of(Reason reason, String message)
{
return (new SlackRefusal(reason, message, null, null, null));
}
}
@@ -0,0 +1,66 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.slack.security;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.support.SlackRuntime;
/*******************************************************************************
** Authenticate raw callback bytes before parsing or retaining any payload.
*******************************************************************************/
public final class SlackRequestVerifier
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackRequestVerifier()
{
}
/*******************************************************************************
** Fail closed with no secret or request data in diagnostics.
*******************************************************************************/
public static boolean verify(SlackQBitConfig config, String timestamp, String signature, byte[] body)
{
try
{
long seconds = Long.parseLong(timestamp);
long now = SlackRuntime.clock().instant().getEpochSecond();
if(seconds < now - 300 || seconds > now + 300 || signature == null || !signature.matches("v0=[0-9a-f]{64}"))
{
return (false);
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SlackSecrets.get(config, config.getSigningSecretEnvName()).getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update(("v0:" + timestamp + ":").getBytes(StandardCharsets.UTF_8));
return (MessageDigest.isEqual(mac.doFinal(body), HexFormat.of().parseHex(signature.substring(3))));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -0,0 +1,53 @@
/*
* 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.security;
import com.kingsrook.qbits.slack.SlackQBitConfig;
/*******************************************************************************
** Allow-listed secret lookup. Diagnostics never contain configuration values.
*******************************************************************************/
public final class SlackSecrets
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackSecrets()
{
}
/*******************************************************************************
** Resolve a permitted environment name, failing closed.
*******************************************************************************/
public static String get(SlackQBitConfig config, String name)
{
if(config == null || name == null || !config.getAllowedEnvNames().contains(name))
{
throw (new IllegalArgumentException("Slack secret unavailable"));
}
String value = config.getSecretResolver().apply(name);
if(value == null || value.isBlank())
{
throw (new IllegalArgumentException("Slack secret unavailable"));
}
return (value);
}
}
@@ -0,0 +1,196 @@
/*
* 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.security;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** The Slack record-security keys (docs/DESIGN.md sections 1, 3 and 14.1).
**
** Two key types guard the qbit's own tables:
** - slack_user: the session's QQQ user id. Connections and messages are locked
** to it on their owner column (requirement R1). Slack admins may hold the
** all-access key, and still stay inside their tenant lock.
** - slack_token_access: a key nobody ever holds. It backs the FieldSecurityLock
** on the token columns, so no user session can read or write them (R5). The
** engine runs as the system user, which core lets through, so only the
** dispatcher and the OAuth callback touch tokens.
**
** The tenant lock is the host's: its securityFields and recordSecurityLocks go
** on slack_workspace, and every child inherits it through a join lock.
**
** Hosts call applyToSession from their authentication customizer, after the
** session's user is set.
*******************************************************************************/
public final class SlackSecurity
{
public static final String KEY_BODY_ACCESS = "slack_body_access";
public static final String KEY_USER = "slack_user";
public static final String KEY_USER_ALL_ACCESS = "slack_user_all_access";
public static final String KEY_TOKEN_ACCESS = "slack_token_access";
/////////////////////////////////////////////////////////////////////////////
// the one value that would open a token field. No session ever holds it, //
// and the key type has no all-access key. //
/////////////////////////////////////////////////////////////////////////////
private static final String NEVER_HELD = "slack-token-access-never-granted";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackSecurity()
{
}
/*******************************************************************************
** The key types, for the producer.
*******************************************************************************/
public static List<QSecurityKeyType> buildSecurityKeyTypes()
{
return (List.of(
new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS),
new QSecurityKeyType().withName(KEY_TOKEN_ACCESS),
new QSecurityKeyType().withName(KEY_BODY_ACCESS)));
}
/*******************************************************************************
** Owner lock: the session's slack_user value must equal the field. Reads and
** writes are both locked, so a user can't insert a row for someone else.
*******************************************************************************/
public static RecordSecurityLock ownerLock(String fieldName)
{
return (new RecordSecurityLock()
.withSecurityKeyType(KEY_USER)
.withFieldName(fieldName)
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE));
}
/*******************************************************************************
** Owner lock reached through a join chain (for example attempt to message).
*******************************************************************************/
public static RecordSecurityLock ownerLock(String fieldName, List<String> joinNameChain)
{
return (ownerLock(fieldName).withJoinNameChain(joinNameChain));
}
/*******************************************************************************
** The lock that keeps a token column away from every non-system session.
** Default DENY, and the only override value is one nobody holds.
*******************************************************************************/
public static FieldSecurityLock tokenFieldLock()
{
return (new FieldSecurityLock()
.withSecurityKeyType(KEY_TOKEN_ACCESS)
.withDefaultBehavior(FieldSecurityLock.Behavior.DENY)
.withOverrideValues(List.of(NEVER_HELD)));
}
/*******************************************************************************
** Put the user id (and, for admins, the all-access key) into a session.
** Idempotent: earlier slack keys are replaced. A blank user gets no key, so
** the owner locks deny.
*******************************************************************************/
public static void applyToSession(QSession session, String userId, boolean allUsers)
{
if(session == null)
{
return;
}
applyBodyPermission(session);
if(session.getSecurityKeyValues() != null)
{
session.getSecurityKeyValues().remove(KEY_USER);
session.getSecurityKeyValues().remove(KEY_USER_ALL_ACCESS);
}
if(userId != null && !userId.isBlank())
{
session.withSecurityKeyValue(KEY_USER, userId);
}
if(allUsers)
{
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
}
}
/*******************************************************************************
** RC.6 field locks use security keys; derive this key from the body grant.
*******************************************************************************/
public static void applyBodyPermission(QSession session)
{
if(session == null)
{
return;
}
if(session.getSecurityKeyValues() != null)
{
session.getSecurityKeyValues().remove(KEY_BODY_ACCESS);
}
if(session.hasPermission("slack.message.body"))
{
session.withSecurityKeyValue(KEY_BODY_ACCESS, true);
}
}
/*******************************************************************************
** Hide body values and prevent filtering, sorting or exporting without access.
*******************************************************************************/
public static FieldSecurityLock bodyFieldLock()
{
return (new FieldSecurityLock().withSecurityKeyType(KEY_BODY_ACCESS)
.withDefaultBehavior(FieldSecurityLock.Behavior.DENY).withOverrideValues(List.of(true)));
}
/*******************************************************************************
** The value the session holds for the owner key, or null.
*******************************************************************************/
public static String userId(QSession session)
{
if(session == null)
{
return (null);
}
List<Serializable> values = session.getSecurityKeyValues(KEY_USER);
return (values == null || values.isEmpty() || values.get(0) == null ? null : String.valueOf(values.get(0)));
}
}
@@ -0,0 +1,230 @@
/*
* 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.security;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Set;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** AES-256-GCM encryption of Slack tokens at rest (docs/DESIGN.md section 3.4).
**
** Stored format: v1:keyId:base64(nonce|ciphertext|tag), with a random 96-bit
** nonce per value. The associated data is a caller-supplied binding string (for
** example slack_connection:12:userId), so a ciphertext copied into another row,
** user or tenant fails to decrypt.
**
** Keys are never in the database. They come from environment variables named by
** the config (tokenKeyEnvName: key id "current"; previousTokenKeyEnvName: key id
** "previous", used to decrypt after a rotation), each the base64 of 32 bytes,
** resolved through the config's secretResolver and its allowedEnvNames.
**
** Every failure is a QException with a generic message: no key, token, binding
** or underlying exception text is ever included.
*******************************************************************************/
public class SlackTokenCipher
{
public static final String VERSION = "v1";
public static final String KEY_CURRENT = "current";
public static final String KEY_PREVIOUS = "previous";
private static final int NONCE_BYTES = 12;
private static final int TAG_BITS = 128;
private static final int KEY_BYTES = 32;
private static final SecureRandom RANDOM = new SecureRandom();
private final SlackQBitConfig config;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public SlackTokenCipher(SlackQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** Encrypt a token with the current key, bound to the given row binding.
*******************************************************************************/
public String encrypt(String plaintext, String binding) throws QException
{
if(plaintext == null || binding == null || binding.isEmpty())
{
throw (new QException("Slack token could not be encrypted"));
}
byte[] key = resolveKey(KEY_CURRENT);
try
{
byte[] nonce = new byte[NONCE_BYTES];
RANDOM.nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_BITS, nonce));
cipher.updateAAD(binding.getBytes(StandardCharsets.UTF_8));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[nonce.length + encrypted.length];
System.arraycopy(nonce, 0, combined, 0, nonce.length);
System.arraycopy(encrypted, 0, combined, nonce.length, encrypted.length);
return (VERSION + ":" + KEY_CURRENT + ":" + Base64.getEncoder().encodeToString(combined));
}
catch(GeneralSecurityException e)
{
//////////////////////////////////////////////////////////////////////
// deliberately no cause and no detail: nothing about the key or //
// the token may escape through an exception //
//////////////////////////////////////////////////////////////////////
throw (new QException("Slack token could not be encrypted"));
}
}
/*******************************************************************************
** Decrypt a stored value for the given row binding, using the key it names.
*******************************************************************************/
public String decrypt(String stored, String binding) throws QException
{
if(stored == null || binding == null || binding.isEmpty())
{
throw (new QException("Slack token could not be decrypted"));
}
String[] parts = stored.split(":", 3);
if(parts.length != 3 || !VERSION.equals(parts[0]) || !Set.of(KEY_CURRENT, KEY_PREVIOUS).contains(parts[1]))
{
throw (new QException("Slack token could not be decrypted"));
}
byte[] key = resolveKey(parts[1]);
try
{
byte[] combined = Base64.getDecoder().decode(parts[2]);
if(combined.length <= NONCE_BYTES)
{
throw (new QException("Slack token could not be decrypted"));
}
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_BITS, combined, 0, NONCE_BYTES));
cipher.updateAAD(binding.getBytes(StandardCharsets.UTF_8));
byte[] plain = cipher.doFinal(combined, NONCE_BYTES, combined.length - NONCE_BYTES);
return (new String(plain, StandardCharsets.UTF_8));
}
catch(GeneralSecurityException | IllegalArgumentException e)
{
/////////////////////////////////////////////////////////////////////
// wrong key, wrong binding, tampering or bad base64: all look the //
// same, with no detail //
/////////////////////////////////////////////////////////////////////
throw (new QException("Slack token could not be decrypted"));
}
}
/*******************************************************************************
** The first 8 hex characters of the SHA-256 of a token: a support-friendly,
** non-reversible hint that tells two tokens apart.
*******************************************************************************/
public String fingerprint(String plaintext) throws QException
{
if(plaintext == null)
{
throw (new QException("Slack token could not be fingerprinted"));
}
try
{
byte[] digest = MessageDigest.getInstance("SHA-256").digest(plaintext.getBytes(StandardCharsets.UTF_8));
return (HexFormat.of().formatHex(digest).substring(0, 8));
}
catch(GeneralSecurityException e)
{
throw (new QException("Slack token could not be fingerprinted"));
}
}
/*******************************************************************************
** Resolve one key (current or previous) through the allow-listed environment
** name. A missing, disallowed or malformed key is a generic failure.
*******************************************************************************/
private byte[] resolveKey(String keyId) throws QException
{
String envName = KEY_CURRENT.equals(keyId) ? config.getTokenKeyEnvName() : config.getPreviousTokenKeyEnvName();
if(envName == null || config.getSecretResolver() == null || config.getAllowedEnvNames() == null || !config.getAllowedEnvNames().contains(envName))
{
throw (new QException("Slack token key is not configured"));
}
String value;
try
{
value = config.getSecretResolver().apply(envName);
}
catch(RuntimeException e)
{
///////////////////////////////////////////////////
// a failing resolver must never leak its text //
///////////////////////////////////////////////////
throw (new QException("Slack token key is not configured"));
}
if(value == null || value.isBlank())
{
throw (new QException("Slack token key is not configured"));
}
try
{
byte[] key = Base64.getDecoder().decode(value.trim());
if(key.length != KEY_BYTES)
{
throw (new QException("Slack token key is not configured"));
}
return (key);
}
catch(IllegalArgumentException e)
{
throw (new QException("Slack token key is not configured"));
}
}
}
@@ -0,0 +1,101 @@
/*
* 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.security;
import java.util.Objects;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.metadata.SlackTables;
import com.kingsrook.qbits.slack.support.SlackStore;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The only reader of stored tokens. A token is read by primary key, in a
** system session, with hidden and password fields switched on (the one place
** that is allowed), and decrypted with the binding of the row it came from, so
** a ciphertext copied into another row or another tenant's row fails.
**
** Callers must already hold an AuthorizedSend from SlackAuthorizer: the row id
** comes from that, never from a request. The plaintext is returned to the
** caller's stack only. It is never logged, stored or put in an exception, and
** every failure here is a generic QException.
*******************************************************************************/
public final class SlackTokens
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackTokens()
{
}
/*******************************************************************************
** The binding of a workspace's bot token: its table, id and tenant key.
*******************************************************************************/
public static String workspaceBinding(Integer workspaceId, String tenantKey)
{
return (SlackTables.WORKSPACE + ":" + workspaceId + ":" + Objects.toString(tenantKey, ""));
}
/*******************************************************************************
** The binding of a connection's user token: its table, id and owner.
*******************************************************************************/
public static String connectionBinding(Integer connectionId, String userId)
{
return (SlackTables.CONNECTION + ":" + connectionId + ":" + Objects.toString(userId, ""));
}
/*******************************************************************************
** The bot token of an authorized workspace row.
*******************************************************************************/
public static String botToken(SlackQBitConfig config, Integer workspaceId) throws QException
{
QRecord row = SlackStore.getWithToken(SlackTables.WORKSPACE, workspaceId);
if(row == null || row.getValueString(SlackTables.BOT_TOKEN) == null)
{
throw (new QException("Slack token is not available"));
}
return (new SlackTokenCipher(config).decrypt(row.getValueString(SlackTables.BOT_TOKEN), workspaceBinding(workspaceId, row.getValueString("tenantKey"))));
}
/*******************************************************************************
** The user token of an authorized connection row.
*******************************************************************************/
public static String userToken(SlackQBitConfig config, Integer connectionId) throws QException
{
QRecord row = SlackStore.getWithToken(SlackTables.CONNECTION, connectionId);
if(row == null || row.getValueString(SlackTables.USER_TOKEN) == null)
{
throw (new QException("Slack token is not available"));
}
return (new SlackTokenCipher(config).decrypt(row.getValueString(SlackTables.USER_TOKEN), connectionBinding(connectionId, row.getValueString("userId"))));
}
}
@@ -0,0 +1,133 @@
/*
* 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.support;
import java.time.Instant;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/*******************************************************************************
** In-memory cache of who is in a channel, per (workspaceId, channelId), kept
** for membershipCacheMinutes (docs/DESIGN.md sections 4.1 and 16). The key
** names the tenant-scoped workspace row, so an entry can never be shared
** across tenants. The map is bounded at MAX_ENTRIES: expired entries go first,
** then the oldest. Nothing is written to disk.
*******************************************************************************/
public class SlackMembershipCache
{
public static final int MAX_ENTRIES = 10_000;
private final Map<String, Entry> entries = new LinkedHashMap<>();
/*******************************************************************************
** What was learned about a channel: its members' Slack user ids, whether the
** workspace bot is one of them, its display name, and when the entry expires.
*******************************************************************************/
public record Entry(Set<String> members, boolean botIsMember, String channelName, Instant expiresAt)
{
}
/*******************************************************************************
** The live entry for a channel, or null when there is none or it expired.
*******************************************************************************/
public synchronized Entry get(Integer workspaceId, String channelId, Instant now)
{
String key = key(workspaceId, channelId);
Entry entry = entries.get(key);
if(entry != null && !entry.expiresAt().isAfter(now))
{
entries.remove(key);
return (null);
}
return (entry);
}
/*******************************************************************************
** Keep an entry, making room first when the map is full.
*******************************************************************************/
public synchronized void put(Integer workspaceId, String channelId, Entry entry, Instant now)
{
String key = key(workspaceId, channelId);
entries.remove(key);
if(entries.size() >= MAX_ENTRIES)
{
entries.values().removeIf(e -> !e.expiresAt().isAfter(now));
}
while(entries.size() >= MAX_ENTRIES)
{
Iterator<String> oldest = entries.keySet().iterator();
oldest.next();
oldest.remove();
}
entries.put(key, entry);
}
/*******************************************************************************
** Forget one channel, after a send shows the cached answer was wrong.
*******************************************************************************/
public synchronized void invalidate(Integer workspaceId, String channelId)
{
entries.remove(key(workspaceId, channelId));
}
/*******************************************************************************
** The number of entries held.
*******************************************************************************/
public synchronized int size()
{
return (entries.size());
}
/*******************************************************************************
** Forget everything (tests).
*******************************************************************************/
public synchronized void clear()
{
entries.clear();
}
/*******************************************************************************
** Map key.
*******************************************************************************/
private static String key(Integer workspaceId, String channelId)
{
return (workspaceId + "/" + channelId);
}
}
@@ -0,0 +1,163 @@
/*
* 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.support;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
/*******************************************************************************
** The client-side limiter (docs/DESIGN.md section 5.3), in memory only: at most
** one post per second per channel, ratePerMinute posts per workspace, and one
** probe every five minutes while a workspace is unhealthy. Waiting under a
** limit is never an attempt: the dispatcher just leaves the message due.
**
** Time is passed in by the caller, so the dispatcher's clock (which tests
** replace) drives it.
*******************************************************************************/
public class SlackRateLimiter
{
public static final int MAX_CHANNELS = 10_000;
private static final long CHANNEL_SPACING_MILLIS = 1000L;
private static final long PROBE_SPACING_MILLIS = 300_000L;
private final Map<String, Instant> lastPostByChannel = new HashMap<>();
private final Map<Integer, ArrayDeque<Instant>> minuteWindows = new HashMap<>();
private final Map<Integer, Instant> lastProbe = new HashMap<>();
/*******************************************************************************
** Milliseconds to wait before a post to this channel is allowed, or 0.
*******************************************************************************/
public synchronized long waitMillis(Integer workspaceId, String channelKey, int ratePerMinute, Instant now)
{
long wait = 0;
Instant last = lastPostByChannel.get(key(workspaceId, channelKey));
if(last != null)
{
wait = Math.max(wait, CHANNEL_SPACING_MILLIS - Duration.between(last, now).toMillis());
}
ArrayDeque<Instant> window = minuteWindows.get(workspaceId);
if(window != null)
{
prune(window, now);
if(window.size() >= ratePerMinute)
{
wait = Math.max(wait, 60_000L - Duration.between(window.peekFirst(), now).toMillis());
}
}
return (Math.max(0, wait));
}
/*******************************************************************************
** Note that a post to the channel is being made now.
*******************************************************************************/
public synchronized void record(Integer workspaceId, String channelKey, Instant now)
{
if(lastPostByChannel.size() >= MAX_CHANNELS)
{
Iterator<Map.Entry<String, Instant>> iterator = lastPostByChannel.entrySet().iterator();
while(iterator.hasNext())
{
if(Duration.between(iterator.next().getValue(), now).toMillis() >= CHANNEL_SPACING_MILLIS)
{
iterator.remove();
}
}
if(lastPostByChannel.size() >= MAX_CHANNELS)
{
lastPostByChannel.clear();
}
}
lastPostByChannel.put(key(workspaceId, channelKey), now);
ArrayDeque<Instant> window = minuteWindows.computeIfAbsent(workspaceId, k -> new ArrayDeque<>());
prune(window, now);
window.addLast(now);
}
/*******************************************************************************
** Milliseconds until an unhealthy workspace may be probed again, or 0.
*******************************************************************************/
public synchronized long probeWaitMillis(Integer workspaceId, Instant now)
{
Instant last = lastProbe.get(workspaceId);
return (last == null ? 0 : Math.max(0, PROBE_SPACING_MILLIS - Duration.between(last, now).toMillis()));
}
/*******************************************************************************
** Note that an unhealthy workspace is being probed now.
*******************************************************************************/
public synchronized void recordProbe(Integer workspaceId, Instant now)
{
lastProbe.put(workspaceId, now);
}
/*******************************************************************************
** Forget everything (tests).
*******************************************************************************/
public synchronized void clear()
{
lastPostByChannel.clear();
minuteWindows.clear();
lastProbe.clear();
}
/*******************************************************************************
** Drop window entries older than a minute.
*******************************************************************************/
private static void prune(ArrayDeque<Instant> window, Instant now)
{
while(!window.isEmpty() && Duration.between(window.peekFirst(), now).toMillis() >= 60_000L)
{
window.removeFirst();
}
}
/*******************************************************************************
** Map key for a workspace's channel.
*******************************************************************************/
private static String key(Integer workspaceId, String channelKey)
{
return (workspaceId + "/" + channelKey);
}
}
@@ -0,0 +1,167 @@
/*
* 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.support;
import java.lang.management.ManagementFactory;
import java.time.Clock;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
import java.util.WeakHashMap;
import com.kingsrook.qbits.slack.SlackQBitConfig;
import com.kingsrook.qbits.slack.SlackQBitProducer;
import com.kingsrook.qbits.slack.transport.SlackClient;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
/*******************************************************************************
** Runtime lookups for the qbit. The config is read from the current QInstance
** on every call, never held, so two instances in one JVM never see each other's
** settings. The JVM-wide state is the runner identity, the clock (tests replace
** it), the HTTP client per config, and the two in-memory caches (the limiter
** and the channel membership cache), which never touch disk.
*******************************************************************************/
public final class SlackRuntime
{
private static final String NODE_ID = ManagementFactory.getRuntimeMXBean().getName() + "/" + UUID.randomUUID().toString().substring(0, 8);
private static volatile Clock clock = Clock.systemUTC();
private static final Map<SlackQBitConfig, SlackClient> CLIENTS = Collections.synchronizedMap(new WeakHashMap<>());
private static final SlackRateLimiter LIMITER = new SlackRateLimiter();
private static final SlackMembershipCache MEMBERSHIP = new SlackMembershipCache();
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SlackRuntime()
{
}
/*******************************************************************************
** The config in the current instance, or null when the qbit isn't installed.
*******************************************************************************/
public static SlackQBitConfig config()
{
return (config(QContext.getQInstance()));
}
/*******************************************************************************
** The config in an instance, or null when the qbit isn't installed.
*******************************************************************************/
public static SlackQBitConfig config(QInstance qInstance)
{
if(qInstance == null || qInstance.getQBits() == null)
{
return (null);
}
for(QBitMetaData qBit : qInstance.getQBits().values())
{
if(SlackQBitProducer.ARTIFACT_ID.equals(qBit.getArtifactId()) && qBit.getConfig() instanceof SlackQBitConfig config)
{
return (config);
}
}
return (null);
}
/*******************************************************************************
** The Slack client for a config.
*******************************************************************************/
public static SlackClient client(SlackQBitConfig config)
{
return (CLIENTS.computeIfAbsent(config, SlackClient::new));
}
/*******************************************************************************
** The clock (tests replace it).
*******************************************************************************/
public static Clock clock()
{
return (clock);
}
/*******************************************************************************
** Replace the clock (tests). Null restores the system clock.
*******************************************************************************/
public static void setClock(Clock newClock)
{
clock = newClock == null ? Clock.systemUTC() : newClock;
}
/*******************************************************************************
** Runner identity, stored on claimed attempts.
*******************************************************************************/
public static String nodeId()
{
return (NODE_ID);
}
/*******************************************************************************
** The client-side limiter.
*******************************************************************************/
public static SlackRateLimiter limiter()
{
return (LIMITER);
}
/*******************************************************************************
** The channel membership cache.
*******************************************************************************/
public static SlackMembershipCache membership()
{
return (MEMBERSHIP);
}
/*******************************************************************************
** Forget the in-memory state and restore the system clock (tests).
*******************************************************************************/
public static void reset()
{
LIMITER.clear();
MEMBERSHIP.clear();
CLIENTS.clear();
clock = Clock.systemUTC();
}
}
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more