From 6549fc103f244a9f91930e073a23d0aab7d3ed1e Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Tue, 7 Jul 2026 23:03:10 +0300 Subject: [PATCH 1/8] feat: add user settings with bulk move opt-out --- lib/commands/help.js | 3 +- lib/commands/settings.js | 106 +++++++++++++++++++++++++++++++++++++++ lib/db.js | 27 ++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 lib/commands/settings.js diff --git a/lib/commands/help.js b/lib/commands/help.js index dffd24e..ef9c241 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -4,6 +4,7 @@ const EVERYONE = [ { cmd: "/pro ping", desc: "See if I'm awake (and how slow I am)" }, { cmd: "/pro info [@user]", desc: "Look up info about a Slack user" }, { cmd: "/pro coin", desc: "Flip a coin and get heads or tails" }, + { cmd: "/pro settings", desc: "Personal settings, like opting out of bulk moves" }, { cmd: "/pro help", desc: "You're looking at it :)" }, ]; @@ -18,7 +19,7 @@ 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 anchor [message]", desc: "Compose or replace this channel's anchored message" }, - { cmd: "/pro anchor poll", desc: "Create or replace this channel's anchor message" }, + { cmd: "/pro anchor poll", desc: "Create or replace this channel's anchored poll" }, { cmd: "/pro anchor nps [days] [title]", desc: "Create an NPS survey anchor (default 7 days)" }, { cmd: "/pro anchor [enable|disable]", desc: "Toggle whether the anchor resurfaces" }, { cmd: "/pro anchor delete", desc: "Delete this channel's anchor and its message" }, diff --git a/lib/commands/settings.js b/lib/commands/settings.js new file mode 100644 index 0000000..bc33dee --- /dev/null +++ b/lib/commands/settings.js @@ -0,0 +1,106 @@ +import { isMoveOptedOut, setMoveOptOut } from "../db.js"; + +const txt = (text) => ({ type: "plain_text", text }); +const eph = (text) => ({ response_type: "ephemeral", text }); + +const MOVE_OPTION = { + text: txt("Allow bulk moves to add me to channels"), + description: txt("Channel managers can move members between channels in bulk."), + value: "moves", +}; + +function buildSettingsModalView({ channel, movesAllowed }) { + return { + type: "modal", + callback_id: "user_settings_modal", + private_metadata: JSON.stringify({ channel }), + title: txt("Settings"), + submit: txt("Save"), + close: txt("Cancel"), + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: "Choose how Prometheus can add you to channels." }, + }, + { + type: "input", + block_id: "move_settings", + optional: true, + label: txt("Moves"), + element: { + type: "checkboxes", + action_id: "value", + options: [MOVE_OPTION], + ...(movesAllowed ? { initial_options: [MOVE_OPTION] } : {}), + }, + }, + ], + }; +} + +async function notify(client, channel, userId, text) { + try { + await client.chat.postEphemeral({ channel, user: userId, text }); + } catch { + try { + await client.chat.postMessage({ channel: userId, text }); + } catch {} + } +} + +async function handleSettingsView({ view, body, client, logger }) { + const userId = body.user.id; + const { channel } = JSON.parse(view.private_metadata); + const selected = view.state.values.move_settings.value.selected_options ?? []; + const movesAllowed = selected.some((o) => o.value === "moves"); + + setMoveOptOut(userId, !movesAllowed); + console.log(`[settings] ${userId} set move opt-out to ${!movesAllowed}`); + + await notify( + client, + channel, + userId, + movesAllowed + ? "Settings saved. Bulk moves can add you to channels." + : "Settings saved. Bulk moves will skip you.", + ); + logger.info(`settings saved for ${userId}: moves ${movesAllowed ? "on" : "off"}`); +} + +export default { + name: "settings", + description: "Manage your personal Prometheus settings", + + async execute({ command: cmd, args, respond, client }) { + const [setting, state] = args; + + if (!setting) { + await client.views.open({ + trigger_id: cmd.trigger_id, + view: buildSettingsModalView({ + channel: cmd.channel_id, + movesAllowed: !isMoveOptedOut(cmd.user_id), + }), + }); + return; + } + + if (setting !== "move" || !["on", "off"].includes(state)) { + return respond(eph("`/pro settings` or `/pro settings move on|off`")); + } + + const enabled = state === "on"; + setMoveOptOut(cmd.user_id, !enabled); + console.log(`[settings] ${cmd.user_id} set move opt-out to ${!enabled}`); + await respond( + eph( + enabled + ? "Turned bulk moves *on* for you. Moves can add you to channels." + : "Turned bulk moves *off* for you. Moves will skip you.", + ), + ); + }, +}; + +export const views = [{ callbackId: "user_settings_modal", handleView: handleSettingsView }]; diff --git a/lib/db.js b/lib/db.js index 4e86d2c..0911b5c 100644 --- a/lib/db.js +++ b/lib/db.js @@ -65,6 +65,14 @@ db.run(` ) `); +db.run(` + CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT PRIMARY KEY, + move_opt_out INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) +`); + db.run(` CREATE TABLE IF NOT EXISTS anchor_polls ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -253,6 +261,13 @@ const statements = { "SELECT channel_id, type, target, blocked_by, blocked_at FROM embed_blocks ORDER BY channel_id, type, target", ), + getMoveOptOut: db.query("SELECT move_opt_out FROM user_settings WHERE user_id = $userId"), + setMoveOptOut: db.query(` + INSERT INTO user_settings (user_id, move_opt_out) VALUES ($userId, $optOut) + ON CONFLICT (user_id) DO UPDATE SET move_opt_out = excluded.move_opt_out, updated_at = unixepoch() + `), + listMoveOptOuts: db.query("SELECT user_id FROM user_settings WHERE move_opt_out = 1"), + getAnchorPollByChannel: db.query( "SELECT * FROM anchor_polls WHERE channel_id = $channelId AND is_current = 1", ), @@ -451,6 +466,18 @@ export function listAllEmbedBlocks() { return statements.listAllEmbedBlocks.all(); } +export function isMoveOptedOut(userId) { + return !!statements.getMoveOptOut.get({ $userId: userId })?.move_opt_out; +} + +export function setMoveOptOut(userId, optOut) { + statements.setMoveOptOut.run({ $userId: userId, $optOut: optOut ? 1 : 0 }); +} + +export function listMoveOptOuts() { + return statements.listMoveOptOuts.all().map((row) => row.user_id); +} + export function getAnchorPoll(channelId) { return statements.getAnchorPollByChannel.get({ $channelId: channelId }); } From 8cf5551eca79259115a1e75b52eaf6341c8f5b9d Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Tue, 7 Jul 2026 23:26:49 +0300 Subject: [PATCH 2/8] feat: add bulk member move engine --- lib/move.js | 174 ++++++++++++++++++++++++++++++++++++++++++++ slack.manifest.yaml | 1 + 2 files changed, 175 insertions(+) create mode 100644 lib/move.js diff --git a/lib/move.js b/lib/move.js new file mode 100644 index 0000000..7dff467 --- /dev/null +++ b/lib/move.js @@ -0,0 +1,174 @@ +import { RateLimiter } from "./ratelimiter.js"; +import { listMoveOptOuts, listChannelBans } from "./db.js"; + +const rateLimiter = new RateLimiter(1000, 5); + +const INVITE_BATCH = 100; + +let cachedBotUserId = null; +let cachedUserTokenId = null; + +async function getBotUserId(botClient) { + if (!cachedBotUserId) { + const auth = await botClient.auth.test(); + cachedBotUserId = auth.user_id; + } + return cachedBotUserId; +} + +async function getUserTokenId(userClient) { + if (!cachedUserTokenId) { + const auth = await userClient.auth.test(); + cachedUserTokenId = auth.user_id; + } + return cachedUserTokenId; +} + +async function ensureBotPresent(botClient, channel, logger) { + try { + await rateLimiter.exec(() => botClient.conversations.join({ channel })); + return true; + } catch (error) { + const err = error.data?.error; + if (err === "already_in_channel") return true; + if (err !== "method_not_supported_for_channel_type") { + logger.info(`move: bot could not join ${channel}: ${err ?? error.message}`); + } + return false; + } +} + +async function ensureUserTokenPresent(botClient, userClient, channel, logger) { + const present = await ensureBotPresent(botClient, channel, logger); + if (!present) return false; + try { + const userId = await getUserTokenId(userClient); + await rateLimiter.exec(() => botClient.conversations.invite({ channel, users: userId })); + return true; + } catch (error) { + const err = error.data?.error; + if (err === "already_in_channel") return true; + logger.info(`move: bot could not add user token to ${channel}: ${err ?? error.message}`); + return false; + } +} + +async function fetchMembers(botClient, channel) { + const members = []; + let cursor; + do { + const res = await rateLimiter.exec(() => + botClient.conversations.members({ channel, cursor, limit: 200 }), + ); + members.push(...(res.members ?? [])); + cursor = res.response_metadata?.next_cursor || undefined; + } while (cursor); + return members; +} + +export async function planMove(botClient, logger, { source, dest, exclude = [] }) { + await Promise.all([ + ensureBotPresent(botClient, source, logger), + ensureBotPresent(botClient, dest, logger), + ]); + + const [sourceMembers, destMembers, selfId] = await Promise.all([ + fetchMembers(botClient, source), + fetchMembers(botClient, dest), + getBotUserId(botClient), + ]); + + const destSet = new Set(destMembers); + const excludeSet = new Set(exclude); + const banned = new Set(listChannelBans(dest).map((b) => b.user_id)); + const optedOut = new Set(listMoveOptOuts()); + + const toInvite = []; + const alreadyIn = []; + const skipped = { excluded: [], banned: [], optedOut: [], self: [] }; + + for (const user of sourceMembers) { + if (user === selfId) skipped.self.push(user); + else if (excludeSet.has(user)) skipped.excluded.push(user); + else if (banned.has(user)) skipped.banned.push(user); + else if (optedOut.has(user)) skipped.optedOut.push(user); + else if (destSet.has(user)) alreadyIn.push(user); + else toInvite.push(user); + } + + return { sourceCount: sourceMembers.length, toInvite, alreadyIn, skipped }; +} + +async function inviteBatch(userClient, logger, dest, users) { + const invited = []; + const failed = []; + for (let i = 0; i < users.length; i += INVITE_BATCH) { + const batch = users.slice(i, i + INVITE_BATCH); + try { + await rateLimiter.exec(() => + userClient.conversations.invite({ channel: dest, users: batch.join(","), force: true }), + ); + invited.push(...batch); + } catch (error) { + failed.push(...batch); + logger.error(`move: invite batch to ${dest} failed: ${error.data?.error ?? error.message}`); + } + } + return { invited, failed }; +} + +async function kickUsers(userClient, logger, source, users) { + const kicked = []; + const kickFailed = []; + for (const user of users) { + try { + await rateLimiter.exec(() => userClient.conversations.kick({ channel: source, user })); + kicked.push(user); + } catch (error) { + if (error.data?.error === "not_in_channel") { + kicked.push(user); + } else { + kickFailed.push(user); + logger.error( + `move: failed to kick ${user} from ${source}: ${error.data?.error ?? error.message}`, + ); + } + } + } + return { kicked, kickFailed }; +} + +export async function executeMove( + botClient, + userClient, + logger, + { source, dest, exclude = [], kick = false }, +) { + const plan = await planMove(botClient, logger, { source, dest, exclude }); + + await ensureUserTokenPresent(botClient, userClient, dest, logger); + + const { invited, failed } = await inviteBatch(userClient, logger, dest, plan.toInvite); + + let kicked = []; + let kickFailed = []; + if (kick) { + await ensureUserTokenPresent(botClient, userClient, source, logger); + const result = await kickUsers(userClient, logger, source, [...invited, ...plan.alreadyIn]); + kicked = result.kicked; + kickFailed = result.kickFailed; + } + + return { + source, + dest, + kick, + sourceCount: plan.sourceCount, + invited, + failed, + alreadyIn: plan.alreadyIn, + skipped: plan.skipped, + kicked, + kickFailed, + }; +} diff --git a/slack.manifest.yaml b/slack.manifest.yaml index aec557f..375d7ac 100644 --- a/slack.manifest.yaml +++ b/slack.manifest.yaml @@ -50,6 +50,7 @@ oauth_config: - commands - groups:history - groups:read + - groups:write.invites - im:read - im:write - mpim:read From b3d3e24a103cfb83d631ac629ff33d500330548a Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Tue, 7 Jul 2026 23:48:33 +0300 Subject: [PATCH 3/8] feat: /pro move --- lib/actions/move_cancel.js | 8 +++ lib/actions/move_confirm.js | 73 ++++++++++++++++++++ lib/commands/help.js | 4 ++ lib/commands/move.js | 128 ++++++++++++++++++++++++++++++++++++ lib/logger.js | 28 ++++++++ lib/public-logger.js | 18 +++++ 6 files changed, 259 insertions(+) create mode 100644 lib/actions/move_cancel.js create mode 100644 lib/actions/move_confirm.js create mode 100644 lib/commands/move.js diff --git a/lib/actions/move_cancel.js b/lib/actions/move_cancel.js new file mode 100644 index 0000000..3838b65 --- /dev/null +++ b/lib/actions/move_cancel.js @@ -0,0 +1,8 @@ +export default { + actionId: "move_cancel", + + async execute({ ack, respond }) { + await ack(); + await respond({ replace_original: true, text: "Move canceled." }); + }, +}; diff --git a/lib/actions/move_confirm.js b/lib/actions/move_confirm.js new file mode 100644 index 0000000..7f6093e --- /dev/null +++ b/lib/actions/move_confirm.js @@ -0,0 +1,73 @@ +import { canManage } from "../perms.js"; +import { executeMove } from "../move.js"; +import { logMove } from "../logger.js"; +import { publicLogMove } from "../public-logger.js"; + +export default { + actionId: "move_confirm", + + async execute({ ack, body, action, respond, client, context, logger }) { + await ack(); + + const { source, dest, kick, exclude } = JSON.parse(action.value); + const movedBy = body.user.id; + + const canSource = await canManage(context.userClient, movedBy, source); + const canDest = await canManage(context.userClient, movedBy, dest); + if (!canSource || !canDest) { + return respond({ + replace_original: true, + text: ":loll: You no longer manage both channels.", + }); + } + + await respond({ + replace_original: true, + text: `${kick ? "Moving" : "Copying"} members to <#${dest}>… this can take a while.`, + }); + + let result; + try { + result = await executeMove(client, context.userClient, logger, { + source, + dest, + kick, + exclude, + }); + } catch (error) { + logger.error( + `[move] execute failed ${source} -> ${dest}: ${error.data?.error ?? error.message}`, + ); + return respond({ + replace_original: true, + text: `Move failed: \`${error.data?.error ?? error.message}\``, + }); + } + + const lines = [`*${result.invited.length}* invited to <#${dest}>`]; + if (result.alreadyIn.length) lines.push(`> ${result.alreadyIn.length} were already in`); + if (result.failed.length) lines.push(`> ${result.failed.length} failed to invite`); + if (result.kick) { + lines.push( + `> Removed ${result.kicked.length} from <#${source}>${result.kickFailed.length ? ` (${result.kickFailed.length} failed)` : ""}`, + ); + } + + await respond({ replace_original: true, text: lines.join("\n") }); + + await Promise.all([ + logMove(client, { source, dest, movedBy, result }), + publicLogMove(client, { + source, + dest, + movedBy, + count: result.kick ? result.kicked.length : result.invited.length, + kick: result.kick, + }), + ]); + + logger.info( + `[move] ${movedBy} ${source} -> ${dest}: invited ${result.invited.length}, kicked ${result.kicked.length}`, + ); + }, +}; diff --git a/lib/commands/help.js b/lib/commands/help.js index ef9c241..528b924 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -18,6 +18,10 @@ const MODERATOR_CMDS = [ 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 move #dest [--kick] [--exclude @user]", + desc: "Copy this channel's members to another (--kick to also remove them here)", + }, { cmd: "/pro anchor [message]", desc: "Compose or replace this channel's anchored message" }, { cmd: "/pro anchor poll", desc: "Create or replace this channel's anchored poll" }, { cmd: "/pro anchor nps [days] [title]", desc: "Create an NPS survey anchor (default 7 days)" }, diff --git a/lib/commands/move.js b/lib/commands/move.js new file mode 100644 index 0000000..bce3c20 --- /dev/null +++ b/lib/commands/move.js @@ -0,0 +1,128 @@ +import { canManage } from "../perms.js"; +import { planMove } from "../move.js"; +import { parseChannelMention } from "../anchorCommon.js"; + +const eph = (text) => ({ response_type: "ephemeral", text }); + +function parseUserMention(token) { + const m = token.match(/^<@([A-Z0-9]+)(\|[^>]+)?>$/); + if (m) return m[1]; + if (/^[UW][A-Z0-9]+$/.test(token)) return token; + return null; +} + +function parseArgs(args) { + let dest = null; + let kick = false; + const exclude = []; + let collectingExclude = false; + + for (const token of args) { + if (token === "--kick") { + kick = true; + collectingExclude = false; + } else if (token === "--exclude") { + collectingExclude = true; + } else if (!dest && parseChannelMention(token)) { + dest = parseChannelMention(token); + } else if (collectingExclude) { + const id = parseUserMention(token); + if (id) exclude.push(id); + } + } + + return { dest, kick, exclude }; +} + +function skipSummary(skipped) { + const parts = []; + if (skipped.excluded.length) parts.push(`${skipped.excluded.length} excluded`); + if (skipped.banned.length) parts.push(`${skipped.banned.length} banned from destination`); + if (skipped.optedOut.length) parts.push(`${skipped.optedOut.length} opted out`); + return parts; +} + +export default { + name: "move", + description: "Copy members from this channel to another (add --kick for a true move)", + + async execute({ command: cmd, args, respond, client, context, logger }) { + const source = cmd.channel_id; + const { dest, kick, exclude } = parseArgs(args); + + if (!dest) { + return respond( + eph("Usage: `/pro move #destination [--kick] [--exclude @user @user]`"), + ); + } + if (dest === source) { + return respond(eph("The destination has to be a different channel.")); + } + + const canSource = await canManage(context.userClient, cmd.user_id, source); + const canDest = await canManage(context.userClient, cmd.user_id, dest); + if (!canSource || !canDest) { + logger.info(`[move] ${cmd.user_id} denied moving ${source} -> ${dest}`); + return respond(eph("You need to manage *both* this channel and the destination.")); + } + + let plan; + try { + plan = await planMove(client, logger, { source, dest, exclude }); + } catch (error) { + logger.error( + `[move] plan failed ${source} -> ${dest}: ${error.data?.error ?? error.message}`, + ); + return respond( + eph(`Couldn't read the channels: \`${error.data?.error ?? error.message}\``), + ); + } + + if (!plan.toInvite.length && !plan.alreadyIn.length) { + return respond(eph("Nobody here to move to <#" + dest + ">.")); + } + + const lines = [ + `*Move review*: <#${source}> -> <#${dest}>`, + `> *${plan.toInvite.length}* member${plan.toInvite.length === 1 ? "" : "s"} will be invited`, + ]; + if (plan.alreadyIn.length) lines.push(`> _${plan.alreadyIn.length} already in <#${dest}>_`); + const skips = skipSummary(plan.skipped); + if (skips.length) lines.push(`> _Skipping: ${skips.join(", ")}_`); + if (kick) { + const kickCount = plan.toInvite.length + plan.alreadyIn.length; + const mins = Math.max(1, Math.ceil(kickCount / 50)); + lines.push( + `> \`--kick\`: members will be *removed from <#${source}>* afterwards (~${mins} min)`, + ); + } + + const value = JSON.stringify({ source, dest, kick, exclude }); + + await respond({ + response_type: "ephemeral", + text: `Move review for <#${dest}>`, + blocks: [ + { type: "section", text: { type: "mrkdwn", text: lines.join("\n") } }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "move_confirm", + style: "primary", + text: { type: "plain_text", text: kick ? "Confirm move" : "Confirm copy" }, + value, + }, + { + type: "button", + action_id: "move_cancel", + text: { type: "plain_text", text: "Cancel" }, + value, + }, + ], + }, + ], + }); + }, +}; diff --git a/lib/logger.js b/lib/logger.js index 7e5af22..08e9489 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -99,6 +99,34 @@ export async function logAdmin(client, { action, adminUser, channel, detail }) { ], }); } +export async function logMove(client, { source, dest, movedBy, result }) { + if (!LOG_CHANNEL) return; + + const skipped = result.skipped; + const skipParts = []; + if (skipped.excluded.length) skipParts.push(`${skipped.excluded.length} excluded`); + if (skipped.banned.length) skipParts.push(`${skipped.banned.length} banned from dest`); + if (skipped.optedOut.length) skipParts.push(`${skipped.optedOut.length} opted out`); + + const lines = [ + `<@${movedBy}> moved members from <#${source}> to <#${dest}>`, + `> *${result.invited.length}* invited - ${result.alreadyIn.length} already in - ${result.sourceCount} in source`, + ]; + if (skipParts.length) lines.push(`> _Skipped: ${skipParts.join(", ")}_`); + if (result.failed.length) lines.push(`> ${result.failed.length} failed to invite`); + if (result.kick) { + lines.push( + `> Removed *${result.kicked.length}* from <#${source}>${result.kickFailed.length ? ` (${result.kickFailed.length} failed)` : ""}`, + ); + } + + await getLogClient(client).chat.postMessage({ + channel: LOG_CHANNEL, + text: `Members moved from <#${source}> to <#${dest}>`, + blocks: [{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } }], + }); +} + export async function logThread(client, logger, { channel, threadTs, messages, deletedBy }) { if (!LOG_CHANNEL) return; // ding dong didnt set it up diff --git a/lib/public-logger.js b/lib/public-logger.js index 8da9552..3c8bee1 100644 --- a/lib/public-logger.js +++ b/lib/public-logger.js @@ -32,6 +32,24 @@ export async function publicLogDelete(client, { channel, deletedBy }) { }); } +export async function publicLogMove(client, { source, dest, movedBy, count, kick }) { + if (!c) return; + + await getLogClient(client).chat.postMessage({ + channel: c, + text: `Members moved to <#${dest}>`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `<@${movedBy}> ${kick ? "moved" : "copied"} ${count} member${count === 1 ? "" : "s"} from <#${source}> to <#${dest}>.`, + }, + }, + ], + }); +} + export async function publicLogThread(client, { channel, messages, deletedBy }) { if (!c) return; From 8fde0ebd24dc4f08d410eeef53c8334d7ff3106f Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Wed, 8 Jul 2026 00:01:12 +0300 Subject: [PATCH 4/8] feat: /pro move modal --- lib/commands/move.js | 254 +++++++++++++++++++++++++++++++------------ 1 file changed, 184 insertions(+), 70 deletions(-) diff --git a/lib/commands/move.js b/lib/commands/move.js index bce3c20..04af2ca 100644 --- a/lib/commands/move.js +++ b/lib/commands/move.js @@ -2,6 +2,7 @@ import { canManage } from "../perms.js"; import { planMove } from "../move.js"; import { parseChannelMention } from "../anchorCommon.js"; +const txt = (text) => ({ type: "plain_text", text }); const eph = (text) => ({ response_type: "ephemeral", text }); function parseUserMention(token) { @@ -42,87 +43,200 @@ function skipSummary(skipped) { return parts; } +function buildReviewMessage({ source, dest, kick, exclude, plan }) { + const lines = [ + `*Move review*: <#${source}> -> <#${dest}>`, + `> *${plan.toInvite.length}* member${plan.toInvite.length === 1 ? "" : "s"} will be invited`, + ]; + if (plan.alreadyIn.length) lines.push(`> _${plan.alreadyIn.length} already in <#${dest}>_`); + const skips = skipSummary(plan.skipped); + if (skips.length) lines.push(`> _Skipping: ${skips.join(", ")}_`); + if (kick) { + lines.push( + `> \`--kick\`: members will be *removed from <#${source}>* afterwards`, + ); + } + + const value = JSON.stringify({ source, dest, kick, exclude }); + + return { + text: `Move review for <#${dest}>`, + blocks: [ + { type: "section", text: { type: "mrkdwn", text: lines.join("\n") } }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "move_confirm", + style: "primary", + text: txt(kick ? "Confirm move" : "Confirm copy"), + value, + }, + { + type: "button", + action_id: "move_cancel", + text: txt("Cancel"), + value, + }, + ], + }, + ], + }; +} + +function buildMoveModalView(source) { + const channelSelect = { + type: "conversations_select", + action_id: "value", + filter: { include: ["public", "private"], exclude_bot_users: true }, + }; + return { + type: "modal", + callback_id: "move_setup_modal", + title: txt("Move members"), + submit: txt("Review"), + close: txt("Cancel"), + blocks: [ + { + type: "input", + block_id: "source", + label: txt("Source channel"), + element: { ...channelSelect, ...(source ? { initial_conversation: source } : {}) }, + }, + { + type: "input", + block_id: "dest", + label: txt("Destination channel"), + element: { ...channelSelect }, + }, + { + type: "input", + block_id: "exclude", + optional: true, + label: txt("Exclude people (optional)"), + element: { type: "multi_users_select", action_id: "value" }, + }, + { + type: "input", + block_id: "options", + optional: true, + label: txt("Options"), + element: { + type: "checkboxes", + action_id: "value", + options: [ + { + text: txt("Also remove them from the source channel"), + description: txt("Kick instead of copy"), + value: "kick", + }, + ], + }, + }, + ], + }; +} + +async function dm(client, userId, text) { + try { + await client.chat.postMessage({ channel: userId, text }); + } catch { } +} + +async function reviewMove({ + source, + dest, + kick, + exclude, + userId, + client, + context, + logger, + deliver, + warn, +}) { + if (source === dest) return warn("The destination has to be a different channel."); + + const canSource = await canManage(context.userClient, userId, source); + const canDest = await canManage(context.userClient, userId, dest); + if (!canSource || !canDest) { + logger.info(`[move] ${userId} denied moving ${source} -> ${dest}`); + return warn("You need to manage *both* the source channel and the destination."); + } + + let plan; + try { + plan = await planMove(client, logger, { source, dest, exclude }); + } catch (error) { + logger.error(`[move] plan failed ${source} -> ${dest}: ${error.data?.error ?? error.message}`); + return warn(`Couldn't read the channels: \`${error.data?.error ?? error.message}\``); + } + + if (!plan.toInvite.length && !plan.alreadyIn.length) { + return warn(`Nobody in <#${source}> to move to <#${dest}>.`); + } + + await deliver(buildReviewMessage({ source, dest, kick, exclude, plan })); +} + +async function handleMoveView({ view, body, client, context, logger }) { + const userId = body.user.id; + const v = view.state.values; + const source = v.source?.value?.selected_conversation; + const dest = v.dest?.value?.selected_conversation; + const exclude = v.exclude?.value?.selected_users ?? []; + const kick = (v.options?.value?.selected_options ?? []).some((o) => o.value === "kick"); + + if (!source || !dest) return dm(client, userId, "Pick both a source and a destination channel."); + + await reviewMove({ + source, + dest, + kick, + exclude, + userId, + client, + context, + logger, + deliver: (msg) => client.chat.postEphemeral({ channel: source, user: userId, ...msg }), + warn: (text) => dm(client, userId, text), + }); +} + export default { name: "move", description: "Copy members from this channel to another (add --kick for a true move)", async execute({ command: cmd, args, respond, client, context, logger }) { + if (!args.length) { + await client.views.open({ + trigger_id: cmd.trigger_id, + view: buildMoveModalView(cmd.channel_id), + }); + return; + } + const source = cmd.channel_id; const { dest, kick, exclude } = parseArgs(args); if (!dest) { - return respond( - eph("Usage: `/pro move #destination [--kick] [--exclude @user @user]`"), - ); - } - if (dest === source) { - return respond(eph("The destination has to be a different channel.")); - } - - const canSource = await canManage(context.userClient, cmd.user_id, source); - const canDest = await canManage(context.userClient, cmd.user_id, dest); - if (!canSource || !canDest) { - logger.info(`[move] ${cmd.user_id} denied moving ${source} -> ${dest}`); - return respond(eph("You need to manage *both* this channel and the destination.")); - } - - let plan; - try { - plan = await planMove(client, logger, { source, dest, exclude }); - } catch (error) { - logger.error( - `[move] plan failed ${source} -> ${dest}: ${error.data?.error ?? error.message}`, - ); - return respond( - eph(`Couldn't read the channels: \`${error.data?.error ?? error.message}\``), - ); - } - - if (!plan.toInvite.length && !plan.alreadyIn.length) { - return respond(eph("Nobody here to move to <#" + dest + ">.")); - } - - const lines = [ - `*Move review*: <#${source}> -> <#${dest}>`, - `> *${plan.toInvite.length}* member${plan.toInvite.length === 1 ? "" : "s"} will be invited`, - ]; - if (plan.alreadyIn.length) lines.push(`> _${plan.alreadyIn.length} already in <#${dest}>_`); - const skips = skipSummary(plan.skipped); - if (skips.length) lines.push(`> _Skipping: ${skips.join(", ")}_`); - if (kick) { - const kickCount = plan.toInvite.length + plan.alreadyIn.length; - const mins = Math.max(1, Math.ceil(kickCount / 50)); - lines.push( - `> \`--kick\`: members will be *removed from <#${source}>* afterwards (~${mins} min)`, - ); + return respond(eph("Usage: `/pro move #destination [--kick] [--exclude @user @user]`")); } - const value = JSON.stringify({ source, dest, kick, exclude }); - - await respond({ - response_type: "ephemeral", - text: `Move review for <#${dest}>`, - blocks: [ - { type: "section", text: { type: "mrkdwn", text: lines.join("\n") } }, - { - type: "actions", - elements: [ - { - type: "button", - action_id: "move_confirm", - style: "primary", - text: { type: "plain_text", text: kick ? "Confirm move" : "Confirm copy" }, - value, - }, - { - type: "button", - action_id: "move_cancel", - text: { type: "plain_text", text: "Cancel" }, - value, - }, - ], - }, - ], + await reviewMove({ + source, + dest, + kick, + exclude, + userId: cmd.user_id, + client, + context, + logger, + deliver: (msg) => respond({ response_type: "ephemeral", ...msg }), + warn: (text) => respond(eph(text)), }); }, }; + +export const views = [{ callbackId: "move_setup_modal", handleView: handleMoveView }]; From 502b0a7e764a0b209589c1e20abe19dd4f97f683 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Wed, 8 Jul 2026 00:20:09 +0300 Subject: [PATCH 5/8] feat: perms --- lib/actions/move_confirm.js | 6 ++---- lib/commands/move.js | 35 ++++++++++------------------------- lib/moderation.js | 9 +++++++++ lib/perms.js | 29 +++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/lib/actions/move_confirm.js b/lib/actions/move_confirm.js index 7f6093e..ecfbd1f 100644 --- a/lib/actions/move_confirm.js +++ b/lib/actions/move_confirm.js @@ -1,4 +1,4 @@ -import { canManage } from "../perms.js"; +import { canMove } from "../perms.js"; import { executeMove } from "../move.js"; import { logMove } from "../logger.js"; import { publicLogMove } from "../public-logger.js"; @@ -12,9 +12,7 @@ export default { const { source, dest, kick, exclude } = JSON.parse(action.value); const movedBy = body.user.id; - const canSource = await canManage(context.userClient, movedBy, source); - const canDest = await canManage(context.userClient, movedBy, dest); - if (!canSource || !canDest) { + if (!(await canMove(client, movedBy, { source, dest }))) { return respond({ replace_original: true, text: ":loll: You no longer manage both channels.", diff --git a/lib/commands/move.js b/lib/commands/move.js index 04af2ca..b8a20ef 100644 --- a/lib/commands/move.js +++ b/lib/commands/move.js @@ -1,4 +1,4 @@ -import { canManage } from "../perms.js"; +import { canMove } from "../perms.js"; import { planMove } from "../move.js"; import { parseChannelMention } from "../anchorCommon.js"; @@ -52,9 +52,7 @@ function buildReviewMessage({ source, dest, kick, exclude, plan }) { const skips = skipSummary(plan.skipped); if (skips.length) lines.push(`> _Skipping: ${skips.join(", ")}_`); if (kick) { - lines.push( - `> \`--kick\`: members will be *removed from <#${source}>* afterwards`, - ); + lines.push(`> \`--kick\`: members will be *removed from <#${source}>* afterwards`); } const value = JSON.stringify({ source, dest, kick, exclude }); @@ -141,28 +139,17 @@ function buildMoveModalView(source) { async function dm(client, userId, text) { try { await client.chat.postMessage({ channel: userId, text }); - } catch { } + } catch {} } -async function reviewMove({ - source, - dest, - kick, - exclude, - userId, - client, - context, - logger, - deliver, - warn, -}) { +async function reviewMove({ source, dest, kick, exclude, userId, client, logger, deliver, warn }) { if (source === dest) return warn("The destination has to be a different channel."); - const canSource = await canManage(context.userClient, userId, source); - const canDest = await canManage(context.userClient, userId, dest); - if (!canSource || !canDest) { + if (!(await canMove(client, userId, { source, dest }))) { logger.info(`[move] ${userId} denied moving ${source} -> ${dest}`); - return warn("You need to manage *both* the source channel and the destination."); + return warn( + "You need to be a Prometheus manager or Slack channel manager of *both* channels (or a global admin).", + ); } let plan; @@ -180,7 +167,7 @@ async function reviewMove({ await deliver(buildReviewMessage({ source, dest, kick, exclude, plan })); } -async function handleMoveView({ view, body, client, context, logger }) { +async function handleMoveView({ view, body, client, logger }) { const userId = body.user.id; const v = view.state.values; const source = v.source?.value?.selected_conversation; @@ -197,7 +184,6 @@ async function handleMoveView({ view, body, client, context, logger }) { exclude, userId, client, - context, logger, deliver: (msg) => client.chat.postEphemeral({ channel: source, user: userId, ...msg }), warn: (text) => dm(client, userId, text), @@ -208,7 +194,7 @@ export default { name: "move", description: "Copy members from this channel to another (add --kick for a true move)", - async execute({ command: cmd, args, respond, client, context, logger }) { + async execute({ command: cmd, args, respond, client, logger }) { if (!args.length) { await client.views.open({ trigger_id: cmd.trigger_id, @@ -231,7 +217,6 @@ export default { exclude, userId: cmd.user_id, client, - context, logger, deliver: (msg) => respond({ response_type: "ephemeral", ...msg }), warn: (text) => respond(eph(text)), diff --git a/lib/moderation.js b/lib/moderation.js index 7f7f628..ef89adf 100644 --- a/lib/moderation.js +++ b/lib/moderation.js @@ -36,6 +36,15 @@ async function moderationAPI(method, params) { return json; } +export async function listChannelManagers(channelId) { + const res = await moderationAPI("admin.roles.entity.listAssignments", { + entity_id: channelId, + role_id: "Rl0A", + }); + const assignment = (res.role_assignments || []).find((a) => a.role_id === "Rl0A"); + return assignment?.users || []; +} + export async function hideThread(channel, ts) { console.log(`[moderation] hiding thread ${ts} in ${channel}`); return moderationAPI("moderation.thread.hide", { diff --git a/lib/perms.js b/lib/perms.js index ac3d764..aefc8f5 100644 --- a/lib/perms.js +++ b/lib/perms.js @@ -3,6 +3,7 @@ import { hasChannelRole as dbHasChannelRole, isAppointedManager as dbIsAppointedManager, } from "./db.js"; +import { areWeEnterprise, listChannelManagers } from "./moderation.js"; export { isGlobalAdmin }; @@ -34,3 +35,31 @@ export const canManage = async (client, userId, channelId) => // create/edit/delete/enable/disable an anchor poll export const canAnchor = async (client, userId, channelId) => (await canManage(client, userId, channelId)) || (await isWorkspaceAdmin(client, userId)); + +export const isSlackChannelManager = async (client, userId, channelId) => { + if (areWeEnterprise) { + try { + const managers = await listChannelManagers(channelId); + if (managers.length) return managers.includes(userId); + } catch { } + } + try { + const info = await client.conversations.info({ channel: channelId }); + return info.channel?.creator === userId; + } catch { + return false; + } +}; + +const canMoveChannel = async (client, userId, channelId) => + dbIsAppointedManager(userId, channelId) || + (await isSlackChannelManager(client, userId, channelId)); + +export const canMove = async (client, userId, { source, dest }) => { + if (isGlobalAdmin(userId)) return true; + const [okSource, okDest] = await Promise.all([ + canMoveChannel(client, userId, source), + canMoveChannel(client, userId, dest), + ]); + return okSource && okDest; +}; From 7036ab9203fd06fd55ecb39af611717ce4cd9595 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Wed, 8 Jul 2026 00:27:03 +0300 Subject: [PATCH 6/8] fmt --- lib/perms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/perms.js b/lib/perms.js index aefc8f5..f1703d4 100644 --- a/lib/perms.js +++ b/lib/perms.js @@ -41,7 +41,7 @@ export const isSlackChannelManager = async (client, userId, channelId) => { try { const managers = await listChannelManagers(channelId); if (managers.length) return managers.includes(userId); - } catch { } + } catch {} } try { const info = await client.conversations.info({ channel: channelId }); From 6adf7a80feb6909cdc70a7d22ea3151956caf46a Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Wed, 8 Jul 2026 03:18:00 +0300 Subject: [PATCH 7/8] fix: removing settings & opting-out --- lib/actions/move_confirm.js | 4 +- lib/commands/help.js | 1 - lib/commands/move.js | 26 ++++++--- lib/commands/settings.js | 106 ------------------------------------ lib/db.js | 27 --------- lib/logger.js | 1 - lib/moderation.js | 9 --- lib/move.js | 6 +- lib/perms.js | 30 +--------- 9 files changed, 24 insertions(+), 186 deletions(-) delete mode 100644 lib/commands/settings.js diff --git a/lib/actions/move_confirm.js b/lib/actions/move_confirm.js index ecfbd1f..ba29a99 100644 --- a/lib/actions/move_confirm.js +++ b/lib/actions/move_confirm.js @@ -12,10 +12,10 @@ export default { const { source, dest, kick, exclude } = JSON.parse(action.value); const movedBy = body.user.id; - if (!(await canMove(client, movedBy, { source, dest }))) { + if (!(await canMove(context.userClient, movedBy, source))) { return respond({ replace_original: true, - text: ":loll: You no longer manage both channels.", + text: ":loll: You're no longer allowed to move members from this channel.", }); } diff --git a/lib/commands/help.js b/lib/commands/help.js index 528b924..f6ad334 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -4,7 +4,6 @@ const EVERYONE = [ { cmd: "/pro ping", desc: "See if I'm awake (and how slow I am)" }, { cmd: "/pro info [@user]", desc: "Look up info about a Slack user" }, { cmd: "/pro coin", desc: "Flip a coin and get heads or tails" }, - { cmd: "/pro settings", desc: "Personal settings, like opting out of bulk moves" }, { cmd: "/pro help", desc: "You're looking at it :)" }, ]; diff --git a/lib/commands/move.js b/lib/commands/move.js index b8a20ef..e66ea15 100644 --- a/lib/commands/move.js +++ b/lib/commands/move.js @@ -39,7 +39,6 @@ function skipSummary(skipped) { const parts = []; if (skipped.excluded.length) parts.push(`${skipped.excluded.length} excluded`); if (skipped.banned.length) parts.push(`${skipped.banned.length} banned from destination`); - if (skipped.optedOut.length) parts.push(`${skipped.optedOut.length} opted out`); return parts; } @@ -142,14 +141,23 @@ async function dm(client, userId, text) { } catch {} } -async function reviewMove({ source, dest, kick, exclude, userId, client, logger, deliver, warn }) { +async function reviewMove({ + source, + dest, + kick, + exclude, + userId, + client, + userClient, + logger, + deliver, + warn, +}) { if (source === dest) return warn("The destination has to be a different channel."); - if (!(await canMove(client, userId, { source, dest }))) { + if (!(await canMove(userClient, userId, source))) { logger.info(`[move] ${userId} denied moving ${source} -> ${dest}`); - return warn( - "You need to be a Prometheus manager or Slack channel manager of *both* channels (or a global admin).", - ); + return warn("You need to be a workspace admin or a Prometheus manager of this channel."); } let plan; @@ -167,7 +175,7 @@ async function reviewMove({ source, dest, kick, exclude, userId, client, logger, await deliver(buildReviewMessage({ source, dest, kick, exclude, plan })); } -async function handleMoveView({ view, body, client, logger }) { +async function handleMoveView({ view, body, client, context, logger }) { const userId = body.user.id; const v = view.state.values; const source = v.source?.value?.selected_conversation; @@ -184,6 +192,7 @@ async function handleMoveView({ view, body, client, logger }) { exclude, userId, client, + userClient: context.userClient, logger, deliver: (msg) => client.chat.postEphemeral({ channel: source, user: userId, ...msg }), warn: (text) => dm(client, userId, text), @@ -194,7 +203,7 @@ export default { name: "move", description: "Copy members from this channel to another (add --kick for a true move)", - async execute({ command: cmd, args, respond, client, logger }) { + async execute({ command: cmd, args, respond, client, context, logger }) { if (!args.length) { await client.views.open({ trigger_id: cmd.trigger_id, @@ -217,6 +226,7 @@ export default { exclude, userId: cmd.user_id, client, + userClient: context.userClient, logger, deliver: (msg) => respond({ response_type: "ephemeral", ...msg }), warn: (text) => respond(eph(text)), diff --git a/lib/commands/settings.js b/lib/commands/settings.js deleted file mode 100644 index bc33dee..0000000 --- a/lib/commands/settings.js +++ /dev/null @@ -1,106 +0,0 @@ -import { isMoveOptedOut, setMoveOptOut } from "../db.js"; - -const txt = (text) => ({ type: "plain_text", text }); -const eph = (text) => ({ response_type: "ephemeral", text }); - -const MOVE_OPTION = { - text: txt("Allow bulk moves to add me to channels"), - description: txt("Channel managers can move members between channels in bulk."), - value: "moves", -}; - -function buildSettingsModalView({ channel, movesAllowed }) { - return { - type: "modal", - callback_id: "user_settings_modal", - private_metadata: JSON.stringify({ channel }), - title: txt("Settings"), - submit: txt("Save"), - close: txt("Cancel"), - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: "Choose how Prometheus can add you to channels." }, - }, - { - type: "input", - block_id: "move_settings", - optional: true, - label: txt("Moves"), - element: { - type: "checkboxes", - action_id: "value", - options: [MOVE_OPTION], - ...(movesAllowed ? { initial_options: [MOVE_OPTION] } : {}), - }, - }, - ], - }; -} - -async function notify(client, channel, userId, text) { - try { - await client.chat.postEphemeral({ channel, user: userId, text }); - } catch { - try { - await client.chat.postMessage({ channel: userId, text }); - } catch {} - } -} - -async function handleSettingsView({ view, body, client, logger }) { - const userId = body.user.id; - const { channel } = JSON.parse(view.private_metadata); - const selected = view.state.values.move_settings.value.selected_options ?? []; - const movesAllowed = selected.some((o) => o.value === "moves"); - - setMoveOptOut(userId, !movesAllowed); - console.log(`[settings] ${userId} set move opt-out to ${!movesAllowed}`); - - await notify( - client, - channel, - userId, - movesAllowed - ? "Settings saved. Bulk moves can add you to channels." - : "Settings saved. Bulk moves will skip you.", - ); - logger.info(`settings saved for ${userId}: moves ${movesAllowed ? "on" : "off"}`); -} - -export default { - name: "settings", - description: "Manage your personal Prometheus settings", - - async execute({ command: cmd, args, respond, client }) { - const [setting, state] = args; - - if (!setting) { - await client.views.open({ - trigger_id: cmd.trigger_id, - view: buildSettingsModalView({ - channel: cmd.channel_id, - movesAllowed: !isMoveOptedOut(cmd.user_id), - }), - }); - return; - } - - if (setting !== "move" || !["on", "off"].includes(state)) { - return respond(eph("`/pro settings` or `/pro settings move on|off`")); - } - - const enabled = state === "on"; - setMoveOptOut(cmd.user_id, !enabled); - console.log(`[settings] ${cmd.user_id} set move opt-out to ${!enabled}`); - await respond( - eph( - enabled - ? "Turned bulk moves *on* for you. Moves can add you to channels." - : "Turned bulk moves *off* for you. Moves will skip you.", - ), - ); - }, -}; - -export const views = [{ callbackId: "user_settings_modal", handleView: handleSettingsView }]; diff --git a/lib/db.js b/lib/db.js index 0911b5c..4e86d2c 100644 --- a/lib/db.js +++ b/lib/db.js @@ -65,14 +65,6 @@ db.run(` ) `); -db.run(` - CREATE TABLE IF NOT EXISTS user_settings ( - user_id TEXT PRIMARY KEY, - move_opt_out INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ) -`); - db.run(` CREATE TABLE IF NOT EXISTS anchor_polls ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -261,13 +253,6 @@ const statements = { "SELECT channel_id, type, target, blocked_by, blocked_at FROM embed_blocks ORDER BY channel_id, type, target", ), - getMoveOptOut: db.query("SELECT move_opt_out FROM user_settings WHERE user_id = $userId"), - setMoveOptOut: db.query(` - INSERT INTO user_settings (user_id, move_opt_out) VALUES ($userId, $optOut) - ON CONFLICT (user_id) DO UPDATE SET move_opt_out = excluded.move_opt_out, updated_at = unixepoch() - `), - listMoveOptOuts: db.query("SELECT user_id FROM user_settings WHERE move_opt_out = 1"), - getAnchorPollByChannel: db.query( "SELECT * FROM anchor_polls WHERE channel_id = $channelId AND is_current = 1", ), @@ -466,18 +451,6 @@ export function listAllEmbedBlocks() { return statements.listAllEmbedBlocks.all(); } -export function isMoveOptedOut(userId) { - return !!statements.getMoveOptOut.get({ $userId: userId })?.move_opt_out; -} - -export function setMoveOptOut(userId, optOut) { - statements.setMoveOptOut.run({ $userId: userId, $optOut: optOut ? 1 : 0 }); -} - -export function listMoveOptOuts() { - return statements.listMoveOptOuts.all().map((row) => row.user_id); -} - export function getAnchorPoll(channelId) { return statements.getAnchorPollByChannel.get({ $channelId: channelId }); } diff --git a/lib/logger.js b/lib/logger.js index 08e9489..28fc9eb 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -106,7 +106,6 @@ export async function logMove(client, { source, dest, movedBy, result }) { const skipParts = []; if (skipped.excluded.length) skipParts.push(`${skipped.excluded.length} excluded`); if (skipped.banned.length) skipParts.push(`${skipped.banned.length} banned from dest`); - if (skipped.optedOut.length) skipParts.push(`${skipped.optedOut.length} opted out`); const lines = [ `<@${movedBy}> moved members from <#${source}> to <#${dest}>`, diff --git a/lib/moderation.js b/lib/moderation.js index ef89adf..7f7f628 100644 --- a/lib/moderation.js +++ b/lib/moderation.js @@ -36,15 +36,6 @@ async function moderationAPI(method, params) { return json; } -export async function listChannelManagers(channelId) { - const res = await moderationAPI("admin.roles.entity.listAssignments", { - entity_id: channelId, - role_id: "Rl0A", - }); - const assignment = (res.role_assignments || []).find((a) => a.role_id === "Rl0A"); - return assignment?.users || []; -} - export async function hideThread(channel, ts) { console.log(`[moderation] hiding thread ${ts} in ${channel}`); return moderationAPI("moderation.thread.hide", { diff --git a/lib/move.js b/lib/move.js index 7dff467..38b1c0f 100644 --- a/lib/move.js +++ b/lib/move.js @@ -1,5 +1,5 @@ import { RateLimiter } from "./ratelimiter.js"; -import { listMoveOptOuts, listChannelBans } from "./db.js"; +import { listChannelBans } from "./db.js"; const rateLimiter = new RateLimiter(1000, 5); @@ -81,17 +81,15 @@ export async function planMove(botClient, logger, { source, dest, exclude = [] } const destSet = new Set(destMembers); const excludeSet = new Set(exclude); const banned = new Set(listChannelBans(dest).map((b) => b.user_id)); - const optedOut = new Set(listMoveOptOuts()); const toInvite = []; const alreadyIn = []; - const skipped = { excluded: [], banned: [], optedOut: [], self: [] }; + const skipped = { excluded: [], banned: [], self: [] }; for (const user of sourceMembers) { if (user === selfId) skipped.self.push(user); else if (excludeSet.has(user)) skipped.excluded.push(user); else if (banned.has(user)) skipped.banned.push(user); - else if (optedOut.has(user)) skipped.optedOut.push(user); else if (destSet.has(user)) alreadyIn.push(user); else toInvite.push(user); } diff --git a/lib/perms.js b/lib/perms.js index f1703d4..0c4f05d 100644 --- a/lib/perms.js +++ b/lib/perms.js @@ -3,7 +3,6 @@ import { hasChannelRole as dbHasChannelRole, isAppointedManager as dbIsAppointedManager, } from "./db.js"; -import { areWeEnterprise, listChannelManagers } from "./moderation.js"; export { isGlobalAdmin }; @@ -36,30 +35,5 @@ export const canManage = async (client, userId, channelId) => export const canAnchor = async (client, userId, channelId) => (await canManage(client, userId, channelId)) || (await isWorkspaceAdmin(client, userId)); -export const isSlackChannelManager = async (client, userId, channelId) => { - if (areWeEnterprise) { - try { - const managers = await listChannelManagers(channelId); - if (managers.length) return managers.includes(userId); - } catch {} - } - try { - const info = await client.conversations.info({ channel: channelId }); - return info.channel?.creator === userId; - } catch { - return false; - } -}; - -const canMoveChannel = async (client, userId, channelId) => - dbIsAppointedManager(userId, channelId) || - (await isSlackChannelManager(client, userId, channelId)); - -export const canMove = async (client, userId, { source, dest }) => { - if (isGlobalAdmin(userId)) return true; - const [okSource, okDest] = await Promise.all([ - canMoveChannel(client, userId, source), - canMoveChannel(client, userId, dest), - ]); - return okSource && okDest; -}; +export const canMove = async (client, userId, channelId) => + (await canManage(client, userId, channelId)) || (await isWorkspaceAdmin(client, userId)); From cde996b9df8915792c5519d9b0596a765057fd2d Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Jul 2026 16:21:38 -0400 Subject: [PATCH 8/8] fix rechecking, keep snapshots, cancel cleanups --- lib/actions/move_cancel.js | 6 +++- lib/actions/move_confirm.js | 33 ++++++++++++++++++--- lib/commands/move.js | 21 +++++++++---- lib/move.js | 59 ++++++++++++++++++++++++++++++++----- 4 files changed, 100 insertions(+), 19 deletions(-) diff --git a/lib/actions/move_cancel.js b/lib/actions/move_cancel.js index 3838b65..10651b7 100644 --- a/lib/actions/move_cancel.js +++ b/lib/actions/move_cancel.js @@ -1,8 +1,12 @@ +import { deleteMoveRequest } from "../move.js"; + export default { actionId: "move_cancel", - async execute({ ack, respond }) { + async execute({ ack, action, respond }) { await ack(); + const { requestId } = JSON.parse(action.value); + deleteMoveRequest(requestId); await respond({ replace_original: true, text: "Move canceled." }); }, }; diff --git a/lib/actions/move_confirm.js b/lib/actions/move_confirm.js index ba29a99..a576d37 100644 --- a/lib/actions/move_confirm.js +++ b/lib/actions/move_confirm.js @@ -1,5 +1,5 @@ import { canMove } from "../perms.js"; -import { executeMove } from "../move.js"; +import { deleteMoveRequest, executeMove, getMoveRequest } from "../move.js"; import { logMove } from "../logger.js"; import { publicLogMove } from "../public-logger.js"; @@ -9,16 +9,40 @@ export default { async execute({ ack, body, action, respond, client, context, logger }) { await ack(); - const { source, dest, kick, exclude } = JSON.parse(action.value); + const { requestId } = JSON.parse(action.value); + const request = getMoveRequest(requestId); const movedBy = body.user.id; - if (!(await canMove(context.userClient, movedBy, source))) { + if (!request) { return respond({ replace_original: true, - text: ":loll: You're no longer allowed to move members from this channel.", + text: "This move review expired. Run `/pro move` again.", }); } + const { source, dest, kick, exclude, plan, requestedBy } = request; + + if (requestedBy !== movedBy) { + return respond({ + replace_original: true, + text: ":loll: Only the person who started this move can confirm it.", + }); + } + + const [canMoveFromSource, canMoveToDest] = await Promise.all([ + canMove(context.userClient, movedBy, source), + canMove(context.userClient, movedBy, dest), + ]); + + if (!canMoveFromSource || !canMoveToDest) { + return respond({ + replace_original: true, + text: ":loll: You're no longer allowed to move members between these channels.", + }); + } + + deleteMoveRequest(requestId); + await respond({ replace_original: true, text: `${kick ? "Moving" : "Copying"} members to <#${dest}>… this can take a while.`, @@ -31,6 +55,7 @@ export default { dest, kick, exclude, + plan, }); } catch (error) { logger.error( diff --git a/lib/commands/move.js b/lib/commands/move.js index e66ea15..c91e666 100644 --- a/lib/commands/move.js +++ b/lib/commands/move.js @@ -1,5 +1,5 @@ import { canMove } from "../perms.js"; -import { planMove } from "../move.js"; +import { createMoveRequest, planMove } from "../move.js"; import { parseChannelMention } from "../anchorCommon.js"; const txt = (text) => ({ type: "plain_text", text }); @@ -42,7 +42,7 @@ function skipSummary(skipped) { return parts; } -function buildReviewMessage({ source, dest, kick, exclude, plan }) { +function buildReviewMessage({ source, dest, kick, requestId, plan }) { const lines = [ `*Move review*: <#${source}> -> <#${dest}>`, `> *${plan.toInvite.length}* member${plan.toInvite.length === 1 ? "" : "s"} will be invited`, @@ -54,7 +54,7 @@ function buildReviewMessage({ source, dest, kick, exclude, plan }) { lines.push(`> \`--kick\`: members will be *removed from <#${source}>* afterwards`); } - const value = JSON.stringify({ source, dest, kick, exclude }); + const value = JSON.stringify({ requestId }); return { text: `Move review for <#${dest}>`, @@ -155,9 +155,16 @@ async function reviewMove({ }) { if (source === dest) return warn("The destination has to be a different channel."); - if (!(await canMove(userClient, userId, source))) { + const [canMoveFromSource, canMoveToDest] = await Promise.all([ + canMove(userClient, userId, source), + canMove(userClient, userId, dest), + ]); + + if (!canMoveFromSource || !canMoveToDest) { logger.info(`[move] ${userId} denied moving ${source} -> ${dest}`); - return warn("You need to be a workspace admin or a Prometheus manager of this channel."); + return warn( + "You need to be a workspace admin or a Prometheus manager of both the source and destination channels.", + ); } let plan; @@ -172,7 +179,9 @@ async function reviewMove({ return warn(`Nobody in <#${source}> to move to <#${dest}>.`); } - await deliver(buildReviewMessage({ source, dest, kick, exclude, plan })); + const requestId = createMoveRequest({ source, dest, kick, exclude, plan, requestedBy: userId }); + + await deliver(buildReviewMessage({ source, dest, kick, requestId, plan })); } async function handleMoveView({ view, body, client, context, logger }) { diff --git a/lib/move.js b/lib/move.js index 38b1c0f..5c4fd11 100644 --- a/lib/move.js +++ b/lib/move.js @@ -1,13 +1,41 @@ +import { randomUUID } from "crypto"; import { RateLimiter } from "./ratelimiter.js"; import { listChannelBans } from "./db.js"; const rateLimiter = new RateLimiter(1000, 5); const INVITE_BATCH = 100; +const MOVE_REQUEST_TTL_MS = 15 * 60 * 1000; + +const pendingMoveRequests = new Map(); let cachedBotUserId = null; let cachedUserTokenId = null; +function cleanupExpiredMoveRequests(now = Date.now()) { + for (const [id, request] of pendingMoveRequests) { + if (now - request.createdAt > MOVE_REQUEST_TTL_MS) { + pendingMoveRequests.delete(id); + } + } +} + +export function createMoveRequest(request) { + cleanupExpiredMoveRequests(); + const id = randomUUID(); + pendingMoveRequests.set(id, { ...request, createdAt: Date.now() }); + return id; +} + +export function getMoveRequest(id) { + cleanupExpiredMoveRequests(); + return pendingMoveRequests.get(id); +} + +export function deleteMoveRequest(id) { + pendingMoveRequests.delete(id); +} + async function getBotUserId(botClient) { if (!cachedBotUserId) { const auth = await botClient.auth.test(); @@ -108,7 +136,22 @@ async function inviteBatch(userClient, logger, dest, users) { ); invited.push(...batch); } catch (error) { - failed.push(...batch); + const perUserErrors = error.data?.errors?.filter((entry) => entry.user); + if (error.data?.error === "already_in_channel" && batch.length === 1) { + invited.push(...batch); + } else if (perUserErrors?.length) { + const failedUsers = new Set( + perUserErrors.filter((entry) => entry.ok !== true).map((entry) => entry.user), + ); + if (failedUsers.size) { + failed.push(...failedUsers); + invited.push(...batch.filter((user) => !failedUsers.has(user))); + } else { + failed.push(...batch); + } + } else { + failed.push(...batch); + } logger.error(`move: invite batch to ${dest} failed: ${error.data?.error ?? error.message}`); } } @@ -140,19 +183,19 @@ export async function executeMove( botClient, userClient, logger, - { source, dest, exclude = [], kick = false }, + { source, dest, exclude = [], kick = false, plan = null }, ) { - const plan = await planMove(botClient, logger, { source, dest, exclude }); + const movePlan = plan ?? (await planMove(botClient, logger, { source, dest, exclude })); await ensureUserTokenPresent(botClient, userClient, dest, logger); - const { invited, failed } = await inviteBatch(userClient, logger, dest, plan.toInvite); + const { invited, failed } = await inviteBatch(userClient, logger, dest, movePlan.toInvite); let kicked = []; let kickFailed = []; if (kick) { await ensureUserTokenPresent(botClient, userClient, source, logger); - const result = await kickUsers(userClient, logger, source, [...invited, ...plan.alreadyIn]); + const result = await kickUsers(userClient, logger, source, [...invited, ...movePlan.alreadyIn]); kicked = result.kicked; kickFailed = result.kickFailed; } @@ -161,11 +204,11 @@ export async function executeMove( source, dest, kick, - sourceCount: plan.sourceCount, + sourceCount: movePlan.sourceCount, invited, failed, - alreadyIn: plan.alreadyIn, - skipped: plan.skipped, + alreadyIn: movePlan.alreadyIn, + skipped: movePlan.skipped, kicked, kickFailed, };