Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/actions/move_cancel.js
Original file line number Diff line number Diff line change
@@ -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." });
},
};
96 changes: 96 additions & 0 deletions lib/actions/move_confirm.js
Original file line number Diff line number Diff line change
@@ -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}`,
);
},
};
6 changes: 5 additions & 1 deletion lib/commands/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
246 changes: 246 additions & 0 deletions lib/commands/move.js
Original file line number Diff line number Diff line change
@@ -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 }];
Loading