diff --git a/lib/actions/move_cancel.js b/lib/actions/move_cancel.js new file mode 100644 index 0000000..10651b7 --- /dev/null +++ b/lib/actions/move_cancel.js @@ -0,0 +1,12 @@ +import { deleteMoveRequest } from "../move.js"; + +export default { + actionId: "move_cancel", + + 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 new file mode 100644 index 0000000..a576d37 --- /dev/null +++ b/lib/actions/move_confirm.js @@ -0,0 +1,96 @@ +import { canMove } from "../perms.js"; +import { deleteMoveRequest, executeMove, getMoveRequest } 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 { requestId } = JSON.parse(action.value); + const request = getMoveRequest(requestId); + const movedBy = body.user.id; + + if (!request) { + return respond({ + replace_original: true, + 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.`, + }); + + let result; + try { + result = await executeMove(client, context.userClient, logger, { + source, + dest, + kick, + exclude, + plan, + }); + } 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 dffd24e..f6ad334 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -17,8 +17,12 @@ 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 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/move.js b/lib/commands/move.js new file mode 100644 index 0000000..c91e666 --- /dev/null +++ b/lib/commands/move.js @@ -0,0 +1,246 @@ +import { canMove } from "../perms.js"; +import { createMoveRequest, 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) { + 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`); + return parts; +} + +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`, + ]; + 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({ requestId }); + + 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, + userClient, + logger, + deliver, + warn, +}) { + if (source === dest) return warn("The destination has to be a different channel."); + + 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 both the source and destination channels.", + ); + } + + 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}>.`); + } + + 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 }) { + 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, + userClient: context.userClient, + 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]`")); + } + + await reviewMove({ + source, + dest, + kick, + exclude, + userId: cmd.user_id, + client, + userClient: context.userClient, + logger, + deliver: (msg) => respond({ response_type: "ephemeral", ...msg }), + warn: (text) => respond(eph(text)), + }); + }, +}; + +export const views = [{ callbackId: "move_setup_modal", handleView: handleMoveView }]; diff --git a/lib/logger.js b/lib/logger.js index 7e5af22..28fc9eb 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -99,6 +99,33 @@ 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`); + + 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/move.js b/lib/move.js new file mode 100644 index 0000000..5c4fd11 --- /dev/null +++ b/lib/move.js @@ -0,0 +1,215 @@ +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(); + 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 toInvite = []; + const alreadyIn = []; + 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 (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) { + 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}`); + } + } + 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, plan = null }, +) { + const movePlan = plan ?? (await planMove(botClient, logger, { source, dest, exclude })); + + await ensureUserTokenPresent(botClient, userClient, dest, logger); + + 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, ...movePlan.alreadyIn]); + kicked = result.kicked; + kickFailed = result.kickFailed; + } + + return { + source, + dest, + kick, + sourceCount: movePlan.sourceCount, + invited, + failed, + alreadyIn: movePlan.alreadyIn, + skipped: movePlan.skipped, + kicked, + kickFailed, + }; +} diff --git a/lib/perms.js b/lib/perms.js index ac3d764..0c4f05d 100644 --- a/lib/perms.js +++ b/lib/perms.js @@ -34,3 +34,6 @@ 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 canMove = async (client, userId, channelId) => + (await canManage(client, userId, channelId)) || (await isWorkspaceAdmin(client, userId)); 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; 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