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, + }, + }, + ], + }; +}