From c7b37627e8423253783803ed358799c146864cde Mon Sep 17 00:00:00 2001 From: Scooter Date: Thu, 27 Aug 2026 23:29:05 +0300 Subject: [PATCH] add optional kick-on-join to posting gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gated channel still fills with messages: every unverified member posts, Prometheus deletes it, and the channel churns. Kick on join stops that at the door — the member is removed as they join and DMed the terms instead. Off by default, so existing gates keep deleting messages and nothing else. It is set with `--kick` on `/pro gate set`, a checkbox in the phrase modal, or `/pro gate kick on|off` afterwards, and shows in `/pro gate status`. Deletion stays the backstop. Anyone who posts without verifying — an existing member, a rejoin, a kick Slack refused — has the message deleted, and in kick mode is then removed and DMed too. Two things follow from the member not being in the channel. Ephemeral messages cannot reach them, so the whole prompt moves into the DM and the agree button trusts the channel in its own value rather than `body.channel.id`, which is now the DM. And the phrase can no longer be typed in the channel, so the DM carries a button opening a modal for it; `lib/actions/index.js` grew a `views` export that skips the loader's ack so that modal can answer `response_action: "errors"` on a wrong phrase. Acknowledging re-invites them on the same user token that did the kicking, so private channels work too. That fires `member_joined_channel` again, but the acceptance is recorded by then, so there is no kick loop. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 19 +- drizzle/0004_closed_victor_mancha.sql | 1 + drizzle/meta/0004_snapshot.json | 961 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/actions/index.js | 15 +- lib/actions/posting_gate_agree.js | 39 +- lib/actions/posting_gate_phrase.js | 150 ++++ lib/commands/gate.js | 109 ++- lib/commands/help.js | 4 + lib/db.js | 26 +- lib/db/schema.ts | 1 + lib/listeners/joinMessage.js | 38 +- lib/listeners/postingGate.js | 22 + lib/postingGate.js | 111 ++- 14 files changed, 1458 insertions(+), 45 deletions(-) create mode 100644 drizzle/0004_closed_victor_mancha.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 lib/actions/posting_gate_phrase.js diff --git a/AGENTS.md b/AGENTS.md index 760c2de..78ed1ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Prometheus is a Slack bot built with `@slack/bolt` in Socket Mode. It runs via ` - `lib/commands/` — subcommands under `/pro `. Each file exports a default with `{ name, execute }`. The router in `index.js` dispatches by name. - `lib/shortcuts/` — message shortcuts (e.g. Delete Message, Destroy Thread). Each exports `{ callbackId, execute, viewCallbackId?, handleView? }`. - `lib/listeners/` — event listeners. Each exports a default function + optional `export const event = 'type'` (defaults to `'message'`) and `priority` (lower runs first). Returning `false` stops later listeners for that event. -- `lib/actions/` — block kit action handlers. Each exports `{ actionId, execute }`. +- `lib/actions/` — block kit action handlers. Each exports `{ actionId, execute }`. A file may also export `views` (an array of `{ callbackId, handleView }`) for modals its buttons open; unlike the command and shortcut loaders, these handlers are responsible for their own `ack()`. **Permission model** (`lib/perms.js`): @@ -47,6 +47,23 @@ All exported database functions are asynchronous and must be awaited. Multi-row **Posting gates** (`lib/postingGate.js`): A channel manager can require either an ephemeral **I agree** button or an exact phrase before members may post. Phrase mode supports optional multiline channel information configured through a modal and shown as a quote before the exact phrase. Join prompts are folded into `joinMessage.js`; missed prompts are retried after deleting an unaccepted message. Each configuration gets an immutable generation embedded in button values, and stale buttons refresh to the current terms without accepting them. `lib/listeners/postingGate.js` runs before other message listeners and returns `false` after handling a gated message so rejected content cannot trigger anchors, embed processing, or other downstream behavior. Phrase acknowledgements are stored before Slack deletes them. +**Kick on join** (`channel_posting_gates.kick_on_join`, toggled with `/pro gate kick on|off` or the +`--kick` / `--no-kick` flag on `/pro gate set`): off by default, in which case an unverified member +stays in the channel and only their messages are removed. When it is on, an unverified member is +removed from the channel as they join and DMed the terms instead, so a busy channel never fills with +gated messages in the first place. Deletion is still the backstop: someone who posts without +verifying — an existing member, a rejoin, a kick Slack refused — has the message deleted and is then +kicked and DMed. Acknowledging re-invites them (`channels:write.invites`). + +Two things follow from the member no longer being in the channel. Ephemeral messages will not reach +them, so the whole prompt (welcome text included) moves into the DM, and the `posting_gate_agree` +action trusts the channel encoded in the button value rather than `body.channel.id`, which is the DM. And a +phrase can no longer be typed in the channel, so the DM carries a **Type the phrase** button opening +the `posting_gate_phrase_entry` modal; `lib/actions/index.js` registers `views` exports without +acking first, so that modal can answer `response_action: "errors"` and stay open on a wrong phrase. +The re-invite fires `member_joined_channel` again, but the acceptance is already recorded by then, so +the gate does not apply and there is no kick loop. + **Slack Connect** (`lib/workspace.js`): `isExternalUser` tells whether a user belongs to another workspace. Prometheus cannot delete or manage members from other workspaces, so `postingGate.js` and `joinMessage.js` skip them instead of prompting and failing forever. The check compares the user's diff --git a/drizzle/0004_closed_victor_mancha.sql b/drizzle/0004_closed_victor_mancha.sql new file mode 100644 index 0000000..30dfd03 --- /dev/null +++ b/drizzle/0004_closed_victor_mancha.sql @@ -0,0 +1 @@ +ALTER TABLE "channel_posting_gates" ADD COLUMN "kick_on_join" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..d0aa793 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,961 @@ +{ + "id": "1018ebce-19f9-47cb-aae6-1b5cc909af40", + "prevId": "a29ecd7c-80a6-4b0f-9a41-6f10765b6fdb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anchor_nps_responses": { + "name": "anchor_nps_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "anchor_nps_responses_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "poll_id": { + "name": "poll_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": { + "anchor_nps_responses_poll_user": { + "name": "anchor_nps_responses_poll_user", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anchor_nps_responses_poll_id_fkey": { + "name": "anchor_nps_responses_poll_id_fkey", + "tableFrom": "anchor_nps_responses", + "tableTo": "anchor_polls", + "columnsFrom": ["poll_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anchor_poll_choices": { + "name": "anchor_poll_choices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "anchor_poll_choices_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "poll_id": { + "name": "poll_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "creator_user_id": { + "name": "creator_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": { + "anchor_poll_choices_position": { + "name": "anchor_poll_choices_position", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anchor_poll_choices_poll_id_fkey": { + "name": "anchor_poll_choices_poll_id_fkey", + "tableFrom": "anchor_poll_choices", + "tableTo": "anchor_polls", + "columnsFrom": ["poll_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anchor_poll_votes": { + "name": "anchor_poll_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "anchor_poll_votes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "poll_id": { + "name": "poll_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "choice_id": { + "name": "choice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": { + "anchor_poll_votes_poll": { + "name": "anchor_poll_votes_poll", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anchor_poll_votes_unique": { + "name": "anchor_poll_votes_unique", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "choice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anchor_poll_votes_poll_id_fkey": { + "name": "anchor_poll_votes_poll_id_fkey", + "tableFrom": "anchor_poll_votes", + "tableTo": "anchor_polls", + "columnsFrom": ["poll_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anchor_poll_votes_choice_id_fkey": { + "name": "anchor_poll_votes_choice_id_fkey", + "tableFrom": "anchor_poll_votes", + "tableTo": "anchor_poll_choices", + "columnsFrom": ["choice_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anchor_polls": { + "name": "anchor_polls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "anchor_polls_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_user_id": { + "name": "creator_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'poll'" + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "anonymous": { + "name": "anonymous", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "multi_select": { + "name": "multi_select", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "add_choice_setting": { + "name": "add_choice_setting", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'no_one'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "closes_at": { + "name": "closes_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_current": { + "name": "is_current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": { + "anchor_polls_channel_current": { + "name": "anchor_polls_channel_current", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anchor_polls\".\"is_current\" = 1", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anchor_polls_channel_history": { + "name": "anchor_polls_channel_history", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointed_managers": { + "name": "appointed_managers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'moderator'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "appointed_managers_pkey": { + "name": "appointed_managers_pkey", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_api_keys": { + "name": "channel_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "channel_api_keys_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + }, + "last_used_at": { + "name": "last_used_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "channel_api_keys_hash": { + "name": "channel_api_keys_hash", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_api_keys_owner": { + "name": "channel_api_keys_owner", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channel_api_keys\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_bans": { + "name": "channel_bans", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "banned_by": { + "name": "banned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires": { + "name": "expires", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "channel_bans_pkey": { + "name": "channel_bans_pkey", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_posting_gate_acceptances": { + "name": "channel_posting_gate_acceptances", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_posting_gate_acceptances_channel_id_fkey": { + "name": "channel_posting_gate_acceptances_channel_id_fkey", + "tableFrom": "channel_posting_gate_acceptances", + "tableTo": "channel_posting_gates", + "columnsFrom": ["channel_id"], + "columnsTo": ["channel_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_posting_gate_acceptances_pkey": { + "name": "channel_posting_gate_acceptances_pkey", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_posting_gates": { + "name": "channel_posting_gates", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phrase": { + "name": "phrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "kick_on_join": { + "name": "kick_on_join", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "set_at": { + "name": "set_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "channel_posting_gates_mode_check": { + "name": "channel_posting_gates_mode_check", + "value": "\"channel_posting_gates\".\"mode\" IN ('button', 'phrase')" + }, + "channel_posting_gates_phrase_check": { + "name": "channel_posting_gates_phrase_check", + "value": "(\"channel_posting_gates\".\"mode\" = 'button' AND \"channel_posting_gates\".\"phrase\" IS NULL) OR (\"channel_posting_gates\".\"mode\" = 'phrase' AND \"channel_posting_gates\".\"phrase\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.embed_blocks": { + "name": "embed_blocks", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_by": { + "name": "blocked_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_at": { + "name": "blocked_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "embed_blocks_pkey": { + "name": "embed_blocks_pkey", + "columns": ["channel_id", "type", "target"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embed_blocks_type_check": { + "name": "embed_blocks_type_check", + "value": "\"embed_blocks\".\"type\" IN ('domain', 'host', 'path')" + } + }, + "isRLSEnabled": false + }, + "public.global_admins": { + "name": "global_admins", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_messages": { + "name": "join_messages", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ephemeral'" + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "set_at": { + "name": "set_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 41ca723..2093b5f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1787750464087, "tag": "0003_square_morlocks", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1787862133737, + "tag": "0004_closed_victor_mancha", + "breakpoints": true } ] } diff --git a/lib/actions/index.js b/lib/actions/index.js index 80c32ad..8b18d54 100644 --- a/lib/actions/index.js +++ b/lib/actions/index.js @@ -5,6 +5,7 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const actions = new Map(); +const views = []; const files = readdirSync(__dirname).filter((f) => f.endsWith(".js") && f !== "index.js"); for (const file of files) { @@ -12,6 +13,7 @@ for (const file of files) { if (mod.default?.actionId) { actions.set(mod.default.actionId, mod.default); } + if (Array.isArray(mod.views)) views.push(...mod.views); } console.log(`[actions] loaded ${actions.size} actions: ${[...actions.keys()].join(", ")}`); @@ -27,6 +29,17 @@ export function registerActions(app) { } }); } + + for (const view of views) { + app.view(view.callbackId, async (args) => { + try { + await view.handleView(args); + } catch (error) { + args.logger.error(`view ${view.callbackId} error: ${error.message}`); + await args.ack(); + } + }); + } } -export { actions }; +export { actions, views }; diff --git a/lib/actions/posting_gate_agree.js b/lib/actions/posting_gate_agree.js index 82d6ccc..ce78735 100644 --- a/lib/actions/posting_gate_agree.js +++ b/lib/actions/posting_gate_agree.js @@ -1,5 +1,9 @@ import { acceptPostingGate, getPostingGateForUser } from "../db.js"; -import { buildPostingGateMessage } from "../postingGate.js"; +import { + buildPostingGateMessage, + gateKicksOnJoin, + inviteAfterPostingGate, +} from "../postingGate.js"; function buttonBinding(value) { try { @@ -14,10 +18,11 @@ function buttonBinding(value) { export default { actionId: "posting_gate_agree", - async execute({ ack, body, action, respond, logger }) { + async execute({ ack, body, action, respond, context, logger }) { await ack(); - const channelId = body.channel?.id || body.container?.channel_id; + const binding = buttonBinding(action.value); + const channelId = binding?.channel || body.channel?.id || body.container?.channel_id; const userId = body.user?.id; if (!channelId || !userId) return; @@ -40,17 +45,11 @@ export default { return; } - const binding = buttonBinding(action.value); - if ( - !binding || - binding.channel !== channelId || - binding.generation !== gate.generation || - gate.mode !== "button" - ) { + if (!binding || binding.generation !== gate.generation || gate.mode !== "button") { await respond({ replace_original: true, response_type: "ephemeral", - ...buildPostingGateMessage(gate, { stale: true }), + ...buildPostingGateMessage(gate, { stale: true, dm: channelId !== body.channel?.id }), }); return; } @@ -63,7 +62,10 @@ export default { ? { replace_original: true, response_type: "ephemeral", - ...buildPostingGateMessage(currentGate, { stale: true }), + ...buildPostingGateMessage(currentGate, { + stale: true, + dm: channelId !== body.channel?.id, + }), } : { replace_original: true, @@ -74,10 +76,21 @@ export default { return; } + let invited = false; + if (gateKicksOnJoin(gate)) { + try { + invited = await inviteAfterPostingGate(context.userClient, channelId, userId); + } catch (error) { + logger.warn(`posting gate invite failed: ${error.data?.error ?? error.message}`); + } + } + await respond({ replace_original: true, response_type: "ephemeral", - text: ":white_check_mark: Thank you! You can now post in this channel.", + text: invited + ? `:white_check_mark: Thank you! You have been added back to <#${channelId}> and can post there now.` + : `:white_check_mark: Thank you! You can now post in <#${channelId}>.`, }); }, }; diff --git a/lib/actions/posting_gate_phrase.js b/lib/actions/posting_gate_phrase.js new file mode 100644 index 0000000..09603c2 --- /dev/null +++ b/lib/actions/posting_gate_phrase.js @@ -0,0 +1,150 @@ +import { acceptPostingGate, getPostingGateForUser } from "../db.js"; +import { + buildPostingGateMessage, + gateKicksOnJoin, + inviteAfterPostingGate, + normalizePostingGatePhrase, + postingGatePhraseModal, +} from "../postingGate.js"; + +function buttonBinding(value) { + try { + const binding = JSON.parse(value); + if (typeof binding.channel !== "string" || typeof binding.generation !== "string") return null; + return binding; + } catch { + return null; + } +} + +export default { + actionId: "posting_gate_phrase", + + async execute({ ack, body, action, respond, client, logger }) { + await ack(); + + const binding = buttonBinding(action.value); + const userId = body.user?.id; + if (!binding || !userId) return; + + const gate = await getPostingGateForUser(binding.channel, userId); + if (!gate) { + await respond({ + replace_original: true, + text: "This acknowledgement is no longer active.", + }); + return; + } + + if (gate.accepted) { + await respond({ + replace_original: true, + text: ":white_check_mark: You have already acknowledged this posting gate, you are all set!", + }); + return; + } + + if (binding.generation !== gate.generation || gate.mode !== "phrase") { + await respond({ + replace_original: true, + ...buildPostingGateMessage(gate, { stale: true, dm: true }), + }); + return; + } + + try { + await client.views.open({ trigger_id: body.trigger_id, view: postingGatePhraseModal(gate) }); + } catch (error) { + logger.warn(`posting gate phrase modal failed: ${error.data?.error ?? error.message}`); + } + }, +}; + +async function submitPhrase({ ack, body, view, client, context, logger }) { + const { channel, generation } = JSON.parse(view.private_metadata); + const userId = body.user.id; + + const gate = await getPostingGateForUser(channel, userId); + if (!gate || gate.accepted || gate.generation !== generation || gate.mode !== "phrase") { + await ack({ + response_action: "update", + view: { + type: "modal", + title: { type: "plain_text", text: "Acknowledge channel" }, + close: { type: "plain_text", text: "Close" }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: gate?.accepted + ? `:white_check_mark: You have already acknowledged <#${channel}>.` + : "This acknowledgement is no longer active. Check your DMs for the current terms.", + }, + }, + ], + }, + }); + + if (gate && !gate.accepted) { + try { + const dm = await client.conversations.open({ users: userId }); + await client.chat.postMessage({ + channel: dm.channel.id, + ...buildPostingGateMessage(gate, { stale: true, dm: true }), + }); + } catch (error) { + logger.warn(`posting gate refresh dm failed: ${error.data?.error ?? error.message}`); + } + } + return; + } + + const entered = view.state.values.posting_gate_entry.phrase.value; + if (normalizePostingGatePhrase(entered) !== normalizePostingGatePhrase(gate.phrase)) { + await ack({ + response_action: "errors", + errors: { posting_gate_entry: "That does not match the required phrase exactly." }, + }); + return; + } + + if (!(await acceptPostingGate(channel, userId, gate))) { + await ack({ + response_action: "errors", + errors: { posting_gate_entry: "This gate just changed. Check your DMs for the new terms." }, + }); + return; + } + + let invited = false; + if (gateKicksOnJoin(gate)) { + try { + invited = await inviteAfterPostingGate(context.userClient, channel, userId); + } catch (error) { + logger.warn(`posting gate invite failed: ${error.data?.error ?? error.message}`); + } + } + + await ack({ + response_action: "update", + view: { + type: "modal", + title: { type: "plain_text", text: "Acknowledge channel" }, + close: { type: "plain_text", text: "Done" }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: invited + ? `:white_check_mark: Thank you! You have been added back to <#${channel}> and can post there now.` + : `:white_check_mark: Thank you! You can now post in <#${channel}>.`, + }, + }, + ], + }, + }); +} + +export const views = [{ callbackId: "posting_gate_phrase_entry", handleView: submitPhrase }]; diff --git a/lib/commands/gate.js b/lib/commands/gate.js index 254981b..4bb5ff5 100644 --- a/lib/commands/gate.js +++ b/lib/commands/gate.js @@ -4,17 +4,33 @@ import { getPostingGate, resetPostingGateAcceptance, setPostingGate, + setPostingGateKickOnJoin, } from "../db.js"; import { logAdmin } from "../logger.js"; import { canManage } from "../perms.js"; -import { escapeMrkdwn } from "../postingGate.js"; +import { escapeMrkdwn, gateKicksOnJoin } from "../postingGate.js"; const eph = (text) => ({ response_type: "ephemeral", text }); const usage = () => eph( - "Usage: `/pro gate set button [explanation]`, `/pro gate set phrase [required phrase]`, `/pro gate status`, `/pro gate reset [@user|all]`, or `/pro gate disable`", + "Usage: `/pro gate set button [explanation]`, `/pro gate set phrase [required phrase]`, `/pro gate kick [on|off]`, `/pro gate status`, `/pro gate reset [@user|all]`, or `/pro gate disable`", ); const parseUser = (text) => text?.match(/^<@([A-Z0-9]+)(?:\|[^>]+)?>$/)?.[1]; +const KICK_FLAG = /(^|\s)--(no-)?kick(?=\s|$)/g; +const takeKickFlag = (text) => { + let kick = null; + const rest = text.replace(KICK_FLAG, (_match, _lead, no) => { + kick = !no; + return " "; + }); + return { kick, rest: rest.replace(/\s+/g, " ").trim() }; +}; + +const kickNotice = (channelId, kick) => + kick + ? `\nUnverified members are removed from <#${channelId}> when they join and are DMed the terms; they are added back once they acknowledge.` + : ""; + const stripQuotes = (text) => { const trimmed = text.trim(); const pairs = [ @@ -41,9 +57,19 @@ async function ensureBotMembership(client, channelId) { } } -function phraseModal(channelId, gate) { +const KICK_OPTION = { + value: "kick", + text: { type: "plain_text", text: "Remove unverified members from the channel" }, + description: { + type: "plain_text", + text: "They are DMed the terms on join and added back once they acknowledge.", + }, +}; + +function phraseModal(channelId, gate, kickDefault) { const details = gate?.mode === "phrase" ? gate.prompt : ""; const phrase = gate?.mode === "phrase" ? gate.phrase : ""; + const kick = kickDefault ?? gateKicksOnJoin(gate); return { type: "modal", callback_id: "posting_gate_phrase_modal", @@ -79,6 +105,18 @@ function phraseModal(channelId, gate) { ...(phrase ? { initial_value: phrase } : {}), }, }, + { + type: "input", + block_id: "posting_gate_kick", + optional: true, + label: { type: "plain_text", text: "Keep the channel clear" }, + element: { + type: "checkboxes", + action_id: "kick", + options: [KICK_OPTION], + ...(kick ? { initial_options: [KICK_OPTION] } : {}), + }, + }, ], }; } @@ -94,11 +132,12 @@ async function savePhraseGate({ body, view, client, context, logger }) { await ensureBotMembership(client, channel); const details = view.state.values.posting_gate_details.details.value?.trim() || ""; const phrase = view.state.values.posting_gate_phrase.phrase.value.trim(); + const kick = Boolean(view.state.values.posting_gate_kick?.kick?.selected_options?.length); if (!phrase) return; - await setPostingGate(channel, "phrase", details, phrase, userId); + await setPostingGate(channel, "phrase", details, phrase, userId, kick ? 1 : 0); logAdmin(client, { - action: "set the posting gate to phrase mode", + action: `set the posting gate to phrase mode${kick ? " with kick on join" : ""}`, adminUser: userId, channel, detail: escapeMrkdwn(`${details ? `${details}\n` : ""}Required phrase: ${phrase}`), @@ -108,7 +147,7 @@ async function savePhraseGate({ body, view, client, context, logger }) { .postEphemeral({ channel, user: userId, - text: `:okay-1: Members must type this before posting:\n> ${escapeMrkdwn(phrase)}`, + text: `:okay-1: Members must type this before posting:\n> ${escapeMrkdwn(phrase)}${kickNotice(channel, kick)}`, }) .catch((error) => logger.warn(`posting gate confirmation failed: ${error.message}`)); } @@ -135,7 +174,9 @@ export default { let prompt = "Before posting, confirm that you understand this channel's purpose."; let phrase = null; - const input = rest.join(" ").trim(); + const { kick: kickFlag, rest: input } = takeKickFlag(rest.join(" ").trim()); + const existing = await getPostingGate(channelId); + const kick = kickFlag ?? gateKicksOnJoin(existing); if (mode === "button") { if (input) prompt = input; @@ -143,10 +184,9 @@ export default { return respond(eph(":red-x: The explanation must be under 2,000 characters.")); } else { if (!input) { - const gate = await getPostingGate(channelId); await client.views.open({ trigger_id: command.trigger_id, - view: phraseModal(channelId, gate), + view: phraseModal(channelId, existing, kickFlag), }); return; } @@ -157,9 +197,9 @@ export default { return respond(eph(":red-x: Keep the phrase under 500 characters.")); } - await setPostingGate(channelId, mode, prompt, phrase, userId); + await setPostingGate(channelId, mode, prompt, phrase, userId, kick ? 1 : 0); logAdmin(client, { - action: `set the posting gate to ${mode} mode`, + action: `set the posting gate to ${mode} mode${kick ? " with kick on join" : ""}`, adminUser: userId, channel: channelId, detail: escapeMrkdwn(mode === "phrase" ? phrase : prompt), @@ -167,9 +207,48 @@ export default { return respond( eph( - mode === "button" - ? `:okay-1: Members must click *I agree* before posting in <#${channelId}>.` - : `:okay-1: Members must type this before posting in <#${channelId}>:\n> ${escapeMrkdwn(phrase)}`, + `${ + mode === "button" + ? `:okay-1: Members must click *I agree* before posting in <#${channelId}>.` + : `:okay-1: Members must type this before posting in <#${channelId}>:\n> ${escapeMrkdwn(phrase)}` + }${kickNotice(channelId, kick)}`, + ), + ); + } + + if (action === "kick") { + const gate = await getPostingGate(channelId); + if (!gate?.enabled) { + return respond(eph("No enabled posting gate exists for this channel. Set one first.")); + } + + if (mode === undefined) { + return respond( + eph( + gateKicksOnJoin(gate) + ? `Unverified members are removed from <#${channelId}> on join. Turn it off with \`/pro gate kick off\`.` + : `Unverified members stay in <#${channelId}> and only have their messages removed. Turn removal on with \`/pro gate kick on\`.`, + ), + ); + } + + if (!["on", "off"].includes(mode)) return respond(usage()); + const kick = mode === "on"; + if (!(await setPostingGateKickOnJoin(channelId, kick))) { + return respond(eph("No enabled posting gate exists for this channel.")); + } + + logAdmin(client, { + action: `turned posting gate kick on join ${mode}`, + adminUser: userId, + channel: channelId, + }).catch((error) => logger.warn(`posting gate audit failed: ${error.message}`)); + + return respond( + eph( + kick + ? `:okay-1: Unverified members will be removed from <#${channelId}> when they join and DMed the terms. They are added back once they acknowledge.` + : `:okay-1: Unverified members will stay in <#${channelId}>; their messages are removed until they acknowledge.`, ), ); } @@ -187,7 +266,7 @@ export default { : ""; return respond( eph( - `*Posting gate for <#${channelId}>*\nStatus: ${gate.enabled ? "enabled" : "disabled"}\nMode: \`${gate.mode}\`\nAccepted members: ${accepted}${details}${gate.phrase ? `\nRequired phrase:\n> ${escapeMrkdwn(gate.phrase)}` : `\nExplanation:\n> ${escapeMrkdwn(gate.prompt)}`}`, + `*Posting gate for <#${channelId}>*\nStatus: ${gate.enabled ? "enabled" : "disabled"}\nMode: \`${gate.mode}\`\nRemove unverified members on join: ${gateKicksOnJoin(gate) ? "yes" : "no"}\nAccepted members: ${accepted}${details}${gate.phrase ? `\nRequired phrase:\n> ${escapeMrkdwn(gate.phrase)}` : `\nExplanation:\n> ${escapeMrkdwn(gate.prompt)}`}`, ), ); } diff --git a/lib/commands/help.js b/lib/commands/help.js index 3ebe014..8ffd89d 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -18,6 +18,10 @@ const MANAGER_CMDS = [ { cmd: "/pro embeds", desc: "Manage blacklisted embeds" }, { cmd: "/pro welcome [set|remove|view]", desc: "Manage the welcome message for new peeps!" }, { cmd: "/pro gate set [button|phrase]", desc: "Require an acknowledgement before posting" }, + { + cmd: "/pro gate kick [on|off]", + desc: "Remove unverified members instead of just their messages", + }, { cmd: "/pro gate [status|reset|disable]", desc: "Manage this channel's posting gate" }, { cmd: "/pro anchor [message]", desc: "Compose or replace this channel's anchored message" }, { cmd: "/pro anchor poll", desc: "Create or replace this channel's anchor message" }, diff --git a/lib/db.js b/lib/db.js index 153bc36..2392cf0 100644 --- a/lib/db.js +++ b/lib/db.js @@ -194,7 +194,7 @@ export async function removewelcome(channelId) { export async function getPostingGate(channelId) { return first( await sql` - SELECT channel_id, mode, prompt, phrase, generation, set_by, enabled, set_at + SELECT channel_id, mode, prompt, phrase, generation, set_by, enabled, kick_on_join, set_at FROM channel_posting_gates WHERE channel_id = ${channelId} `, @@ -205,7 +205,7 @@ export async function getPostingGateForUser(channelId, userId) { return first( await sql` SELECT g.channel_id, g.mode, g.prompt, g.phrase, g.generation, - g.set_by, g.enabled, g.set_at, + g.set_by, g.enabled, g.kick_on_join, g.set_at, EXISTS ( SELECT 1 FROM channel_posting_gate_acceptances a WHERE a.channel_id = g.channel_id AND a.user_id = ${userId} @@ -216,15 +216,16 @@ export async function getPostingGateForUser(channelId, userId) { ); } -export async function setPostingGate(channelId, mode, prompt, phrase, setBy) { +export async function setPostingGate(channelId, mode, prompt, phrase, setBy, kickOnJoin = 0) { const generation = randomUUID(); return sql.begin(async (tx) => { await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`posting_gate:${channelId}`}, 0))`; const gate = first( await tx` INSERT INTO channel_posting_gates - (channel_id, mode, prompt, phrase, generation, set_by) - VALUES (${channelId}, ${mode}, ${prompt}, ${phrase ?? null}, ${generation}, ${setBy}) + (channel_id, mode, prompt, phrase, generation, set_by, kick_on_join) + VALUES (${channelId}, ${mode}, ${prompt}, ${phrase ?? null}, ${generation}, ${setBy}, + ${kickOnJoin ? 1 : 0}) ON CONFLICT (channel_id) DO UPDATE SET mode = EXCLUDED.mode, prompt = EXCLUDED.prompt, @@ -232,8 +233,10 @@ export async function setPostingGate(channelId, mode, prompt, phrase, setBy) { generation = EXCLUDED.generation, set_by = EXCLUDED.set_by, enabled = 1, + kick_on_join = EXCLUDED.kick_on_join, set_at = ${sql.unsafe(epoch)} - RETURNING channel_id, mode, prompt, phrase, generation, set_by, enabled, set_at + RETURNING channel_id, mode, prompt, phrase, generation, set_by, enabled, + kick_on_join, set_at `, ); await tx`DELETE FROM channel_posting_gate_acceptances WHERE channel_id = ${channelId}`; @@ -253,6 +256,17 @@ export async function disablePostingGate(channelId) { ); } +export async function setPostingGateKickOnJoin(channelId, kickOnJoin) { + return first( + await sql` + UPDATE channel_posting_gates SET kick_on_join = ${kickOnJoin ? 1 : 0} + WHERE channel_id = ${channelId} AND enabled = 1 + RETURNING channel_id, mode, prompt, phrase, generation, set_by, enabled, + kick_on_join, set_at + `, + ); +} + export async function acceptPostingGate(channelId, userId, gate) { return Boolean( first( diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 7cb71bc..688ae95 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -63,6 +63,7 @@ export const channelPostingGates = pgTable( generation: text().notNull(), setBy: text("set_by").notNull(), enabled: integer().notNull().default(1), + kickOnJoin: integer("kick_on_join").notNull().default(0), setAt: bigint("set_at", { mode: "number" }).notNull().default(epoch), }, (table) => [ diff --git a/lib/listeners/joinMessage.js b/lib/listeners/joinMessage.js index 90acf1d..841ae0f 100644 --- a/lib/listeners/joinMessage.js +++ b/lib/listeners/joinMessage.js @@ -1,6 +1,11 @@ import { getPostingGateForUser, getwelcome } from "../db.js"; import { canManage } from "../perms.js"; -import { postPostingGatePrompt } from "../postingGate.js"; +import { + gateKicksOnJoin, + kickForPostingGate, + postPostingGateDm, + postPostingGatePrompt, +} from "../postingGate.js"; import { isExternalUser } from "../workspace.js"; export const event = "member_joined_channel"; @@ -17,7 +22,9 @@ export default async function welcomeListener({ event: ev, client, context, logg const gateApplies = gate && !gate.accepted && !(await canManage(context.userClient, ev.user, ev.channel)); - if (jm?.mode === "dm") { + const kicking = gateApplies && gateKicksOnJoin(gate); + + if (jm?.mode === "dm" && !kicking) { console.log(`[joinmessage] sending dm welcome to ${ev.user} in ${ev.channel}`); try { const dm = await client.conversations.open({ users: ev.user }); @@ -30,6 +37,33 @@ export default async function welcomeListener({ event: ev, client, context, logg } } + if (kicking) { + console.log(`[postinggate] kicking unverified ${ev.user} from ${ev.channel}`); + try { + await kickForPostingGate(context.userClient, ev.channel, ev.user); + } catch (e) { + logger.warn(`posting gate kick failed: ${e.data?.error ?? e.message}`); + try { + await postPostingGatePrompt(client, ev.channel, ev.user, gate, { + welcome: jm?.mode === "ephemeral" ? jm.message : null, + }); + } catch (err) { + logger.warn(`posting gate join prompt failed: ${err.data?.error ?? err.message}`); + } + return; + } + + try { + await postPostingGateDm(client, ev.user, gate, { + kicked: true, + welcome: jm ? jm.message : null, + }); + } catch (e) { + logger.warn(`posting gate kick dm failed: ${e.data?.error ?? e.message}`); + } + return; + } + if (gateApplies) { console.log(`[postinggate] sending join prompt to ${ev.user} in ${ev.channel}`); try { diff --git a/lib/listeners/postingGate.js b/lib/listeners/postingGate.js index 4a2f76a..8da61d4 100644 --- a/lib/listeners/postingGate.js +++ b/lib/listeners/postingGate.js @@ -2,8 +2,11 @@ import { acceptPostingGate, getPostingGateForUser } from "../db.js"; import { canManage } from "../perms.js"; import { isExternalUser } from "../workspace.js"; import { + gateKicksOnJoin, + kickForPostingGate, normalizePostingGatePhrase, postPostingGateAccepted, + postPostingGateDm, postPostingGatePrompt, } from "../postingGate.js"; @@ -73,6 +76,25 @@ export default async function postingGateListener({ event, client, context, logg } await deleteMessage(context, logger, message); + + if (gateKicksOnJoin(gate)) { + let kicked = false; + try { + kicked = await kickForPostingGate(context.userClient, message.channel, message.user); + } catch (error) { + logger.warn(`posting gate kick failed: ${error.data?.error ?? error.message}`); + } + + if (kicked) { + try { + await postPostingGateDm(client, message.user, gate, { retry: true, kicked: true }); + } catch (error) { + logger.warn(`posting gate kick dm failed: ${error.data?.error ?? error.message}`); + } + return false; + } + } + try { await postPostingGatePrompt(client, message.channel, message.user, gate, { retry: true }); } catch (error) { diff --git a/lib/postingGate.js b/lib/postingGate.js index 29c89a2..06418b2 100644 --- a/lib/postingGate.js +++ b/lib/postingGate.js @@ -9,15 +9,20 @@ export const normalizePostingGatePhrase = (text) => export const escapeMrkdwn = (text) => String(text).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -const phraseGateIntroduction = - "Before posting, please confirm that you understand the channel's intended purpose and rules."; +const phraseGateIntroduction = (channelId) => + `Before posting${channelId ? ` in <#${channelId}>` : ""}, please confirm that you understand the channel's intended purpose and rules.`; const quoted = (text) => escapeMrkdwn(text) .split("\n") .map((line) => `> ${line}`) .join("\n"); -export function buildPostingGateMessage(gate, { retry = false, stale = false, welcome } = {}) { +export const gateKicksOnJoin = (gate) => Boolean(gate?.kick_on_join); + +export function buildPostingGateMessage( + gate, + { retry = false, stale = false, welcome, dm = false, kicked = false } = {}, +) { const blocks = []; if (welcome) { @@ -27,7 +32,18 @@ export function buildPostingGateMessage(gate, { retry = false, stale = false, we }); } - const notice = `${retry ? "Your message was removed because you haven't acknowledged this channel yet.\n\n" : ""}${stale ? "This posting gate changed since that prompt was sent. Review the current terms below.\n\n" : ""}`; + const notice = [ + kicked + ? `You were removed from <#${gate.channel_id}> because it requires an acknowledgement before you can take part. Complete it below and Prometheus will add you straight back.` + : "", + retry ? "Your message was removed because you haven't acknowledged this channel yet." : "", + stale + ? "This posting gate changed since that prompt was sent. Review the current terms below." + : "", + ] + .filter(Boolean) + .map((line) => `${line}\n\n`) + .join(""); blocks.push({ type: "section", @@ -35,11 +51,13 @@ export function buildPostingGateMessage(gate, { retry = false, stale = false, we type: "mrkdwn", text: gate.mode === "phrase" - ? `${notice}${phraseGateIntroduction}` + ? `${notice}${phraseGateIntroduction(dm ? gate.channel_id : null)}` : `${notice}${escapeMrkdwn(gate.prompt)}`, }, }); + const value = JSON.stringify({ channel: gate.channel_id, generation: gate.generation }); + if (gate.mode === "button") { blocks.push({ type: "actions", @@ -49,7 +67,7 @@ export function buildPostingGateMessage(gate, { retry = false, stale = false, we action_id: "posting_gate_agree", text: { type: "plain_text", text: "I agree" }, style: "primary", - value: JSON.stringify({ channel: gate.channel_id, generation: gate.generation }), + value, }, ], }); @@ -64,9 +82,25 @@ export function buildPostingGateMessage(gate, { retry = false, stale = false, we type: "section", text: { type: "mrkdwn", - text: `To continue, type this exact phrase in the channel and hit send:\n${quoted(gate.phrase)}`, + text: dm + ? `To continue, tap *Type the phrase* and enter this exactly:\n${quoted(gate.phrase)}` + : `To continue, type this exact phrase in the channel and hit send:\n${quoted(gate.phrase)}`, }, }); + if (dm) { + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + action_id: "posting_gate_phrase", + text: { type: "plain_text", text: "Type the phrase" }, + style: "primary", + value, + }, + ], + }); + } } return { @@ -86,6 +120,14 @@ export async function postPostingGatePrompt(client, channelId, userId, gate, opt }); } +export async function postPostingGateDm(client, userId, gate, options) { + const dm = await client.conversations.open({ users: userId }); + await client.chat.postMessage({ + channel: dm.channel.id, + ...buildPostingGateMessage(gate, { ...options, dm: true }), + }); +} + export async function postPostingGateAccepted(client, channelId, userId) { await client.chat.postEphemeral({ channel: channelId, @@ -93,3 +135,58 @@ export async function postPostingGateAccepted(client, channelId, userId) { text: ":white_check_mark: Thanks! You are all set to post in this channel.", }); } + +export async function kickForPostingGate(userClient, channelId, userId) { + try { + await userClient.conversations.kick({ channel: channelId, user: userId }); + return true; + } catch (error) { + const code = error.data?.error ?? error.message; + if (code === "not_in_channel" || code === "user_not_in_channel") return true; + throw error; + } +} + +export async function inviteAfterPostingGate(client, channelId, userId) { + try { + await client.conversations.invite({ channel: channelId, users: userId }); + return true; + } catch (error) { + const code = error.data?.error ?? error.message; + if (code === "already_in_channel" || code === "cant_invite_self") return true; + throw error; + } +} + +export function postingGatePhraseModal(gate) { + return { + type: "modal", + callback_id: "posting_gate_phrase_entry", + private_metadata: JSON.stringify({ + channel: gate.channel_id, + generation: gate.generation, + }), + title: { type: "plain_text", text: "Acknowledge channel" }, + submit: { type: "plain_text", text: "Submit" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: [ + ...(gate.prompt + ? [{ type: "section", text: { type: "mrkdwn", text: quoted(gate.prompt) } }] + : []), + { + type: "section", + text: { type: "mrkdwn", text: `Type this exact phrase:\n${quoted(gate.phrase)}` }, + }, + { + type: "input", + block_id: "posting_gate_entry", + label: { type: "plain_text", text: "Phrase" }, + element: { + type: "plain_text_input", + action_id: "phrase", + max_length: 500, + }, + }, + ], + }; +}