Skip to content
Merged
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
39 changes: 24 additions & 15 deletions app/api/slack/interactions/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -449,22 +449,31 @@ export async function POST(request) {
});
}

try {
const view = await createSlackStartedSpaceModalView({ teamId, slackUserId, name: roomName });
after(async () => {
try {
await publishSlackAppHome({ teamId, slackUserId });
} catch (error) {
console.error("Slack App Home refresh after room creation failed", { message: error.message, slackError: error.slack?.error });
// Creating the room can now create the Slack reads channel inline, which
// adds Slack API round-trips. Respond instantly with a loading modal and
// do the work in after(), then swap in the result — so we never risk the
// ~3s view_submission timeout reporting a failure for a room that was made.
const viewId = slackModalViewId(payload);
after(async () => {
try {
const view = await createSlackStartedSpaceModalView({ teamId, slackUserId, name: roomName });
if (viewId) await updateSlackView({ teamId, viewId, view });
await publishSlackAppHome({ teamId, slackUserId });
} catch (error) {
console.error("Slack room creation failed", { message: error.message, slackError: error.slack?.error });
if (viewId) {
await updateSlackView({
teamId,
viewId,
view: slackLoadingModalView({ title: "couldn't create room", message: error.message || "try again in a moment." }),
}).catch(() => {});
}
});
return ok({ response_action: "update", view });
} catch (error) {
return ok({
response_action: "errors",
errors: { room_name: error.message || "couldn't create that room yet." },
});
}
}
});
return ok({
response_action: "update",
view: slackLoadingModalView({ title: "creating room", message: "setting up your room and reads channel…" }),
});
}

if (payload.type === "view_submission" && payload.view?.callback_id === "share_room_invite") {
Expand Down
114 changes: 90 additions & 24 deletions src/server/slack.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function slackInstallUrl() {
return `https://slack.com/oauth/v2/authorize?${params.toString()}`;
}

export function slackTeamReadsInstallUrl({ setupId, setupToken }) {
export function slackTeamReadsInstallUrl({ setupId, setupToken, teamId = "" }) {
const { appUrl, slackClientId } = assertSlackEnv();
const params = new URLSearchParams({
client_id: slackClientId,
Expand All @@ -37,6 +37,12 @@ export function slackTeamReadsInstallUrl({ setupId, setupToken }) {
state: createSlackState({ setupId, setupToken }),
});

// Pin the consent to the workspace the user is acting from. Without this,
// Slack authorizes against whatever workspace the browser session defaults
// to — the wrong one for anyone signed into multiple workspaces.
const pinnedTeam = cleanString(teamId, 80);
if (pinnedTeam) params.set("team", pinnedTeam);

return `https://slack.com/oauth/v2/authorize?${params.toString()}`;
}

Expand Down Expand Up @@ -224,24 +230,32 @@ export async function createSlackSpaceChannel({ oauthResult, setup }) {
const slackBotToken = cleanString(oauthResult.access_token, 4096);
if (!teamId || !slackBotToken) throw new Error("Slack team reads install was missing workspace access.");

const channel = await createPrivateChannel({
return createSlackChannelForSpace({
teamId,
token: slackBotToken,
name: channelNameForSpace(setup.spaces),
createdBy: cleanString(oauthResult.authed_user?.id, 80),
spaceId: setup.space_id,
space: setup.spaces,
roomAccessToken: cleanString(setup.roomAccessToken, 256),
});
const createdBy = cleanString(oauthResult.authed_user?.id, 80);
}

// Core channel creation, independent of how the bot token was obtained (a fresh
// team-reads OAuth grant, or the elevated token already stored for the workspace).
async function createSlackChannelForSpace({ teamId, token, createdBy, spaceId, space, roomAccessToken = "" }) {
const channel = await createPrivateChannel({ token, name: channelNameForSpace(space) });
if (createdBy) {
await inviteUserToSlackChannel({ token: slackBotToken, channelId: channel.id, slackUserId: createdBy });
await inviteUserToSlackChannel({ token, channelId: channel.id, slackUserId: createdBy });
}

const roomAccessToken = cleanString(setup.roomAccessToken, 256);
const encryptedAccess = roomAccessToken ? encryptSlackSecret(roomAccessToken) : null;

const supabase = getSupabaseAdmin();
const { data, error } = await supabase
.from("slack_space_channels")
.upsert(
{
space_id: setup.space_id,
space_id: spaceId,
slack_team_id: teamId,
slack_channel_id: channel.id,
slack_channel_name: channel.name,
Expand All @@ -266,12 +280,50 @@ export async function createSlackSpaceChannel({ oauthResult, setup }) {
if (error) throw error;
if (createdBy) {
const connection = await findSlackConnectionForAutoPin({ teamId, slackUserId: createdBy });
if (connection) await pinSlackSpace({ connection, spaceId: setup.space_id });
if (connection) await pinSlackSpace({ connection, spaceId });
}
await postSlackChannelIntro({ token: slackBotToken, channel: data, space: setup.spaces, roomAccessToken });
await postSlackChannelIntro({ token, channel: data, space, roomAccessToken });
return data;
}

// If the workspace has already granted the team-reads (private channel) scopes,
// create the reads channel inline using the stored bot token — no second OAuth.
// Returns the channel on success, or null if the workspace hasn't granted the
// scopes yet or the stored token can no longer create channels (caller then
// falls back to surfacing the one-time upgrade link).
async function tryAutoCreateSlackSpaceChannel({ teamId, slackUserId, spaceId, space, roomAccessToken = "" }) {
let installation;
try {
installation = await getSlackInstallation(teamId);
} catch {
return null;
}
if (missingSlackScopes(installation?.scopes, SLACK_TEAM_READS_SCOPES).length) return null;

let token;
try {
token = decryptSlackToken({
ciphertext: installation.bot_access_token_ciphertext,
iv: installation.bot_access_token_iv,
tag: installation.bot_access_token_tag,
});
} catch {
return null;
}

try {
return await createSlackChannelForSpace({ teamId, token, createdBy: slackUserId, spaceId, space, roomAccessToken });
} catch (error) {
// Token revoked or scope dropped since it was stored — fall back to the link.
console.error("Auto team-reads channel create failed; falling back to upgrade link", {
spaceId,
slackError: error.slack?.error,
message: error.message,
});
return null;
}
}

export async function createSlackStartedSpace({ teamId, slackUserId, name }) {
const supabase = getSupabaseAdmin();
const connection = await findSlackConnectionForAutoPin({ teamId, slackUserId });
Expand Down Expand Up @@ -323,18 +375,31 @@ export async function createSlackStartedSpace({ teamId, slackUserId, name }) {

const handoff = await createCreatorHandoff({ spaceId: insertedSpace.id, creatorToken, accessToken });

// If this workspace already granted team-reads scopes (anyone enabled reads
// once before), create the reads channel inline now — no second OAuth. Only
// fall back to the one-time upgrade link when the scope isn't there yet.
const teamReadsChannel = await tryAutoCreateSlackSpaceChannel({
teamId,
slackUserId,
spaceId: insertedSpace.id,
space: insertedSpace,
roomAccessToken: accessToken,
});

// The room already exists at this point. If building the optional team-reads
// install link fails (e.g. missing Slack OAuth env), don't fail the whole
// creation — the user already has a working room. Just omit the button.
let teamReadsUrl = null;
try {
const teamReadsSetup = await createTeamReadsSetup({ spaceId: insertedSpace.id });
teamReadsUrl = slackTeamReadsInstallUrl(teamReadsSetup);
} catch (error) {
console.error("Slack team reads setup link failed after room creation", {
spaceId: insertedSpace.id,
message: error.message,
});
if (!teamReadsChannel) {
try {
const teamReadsSetup = await createTeamReadsSetup({ spaceId: insertedSpace.id });
teamReadsUrl = slackTeamReadsInstallUrl({ ...teamReadsSetup, teamId });
} catch (error) {
console.error("Slack team reads setup link failed after room creation", {
spaceId: insertedSpace.id,
message: error.message,
});
}
}

return {
Expand All @@ -346,6 +411,7 @@ export async function createSlackStartedSpace({ teamId, slackUserId, name }) {
openUrl: slackSpaceHandoffUrl(handoff),
roomUrl: slackRoomReadsUrl(insertedSpace, accessToken),
teamReadsUrl,
teamReadsChannelName: teamReadsChannel?.slack_channel_name || null,
};
}

Expand Down Expand Up @@ -722,7 +788,7 @@ export async function slackPinnedSpacesView({ teamId, slackUserId, notice = "" }
const connection = await findSlackConnection({ teamId, slackUserId });
if (!connection) return slackPinnedSpacesEmptyModal({ connected: false });
const pinnedSpaces = await listSlackPinnedSpaces(connection);
return pinnedSpaces.length ? await slackPinnedSpacesModal({ pinnedSpaces, notice }) : slackPinnedSpacesEmptyModal({ connected: true });
return pinnedSpaces.length ? await slackPinnedSpacesModal({ pinnedSpaces, teamId, notice }) : slackPinnedSpacesEmptyModal({ connected: true });
}

export async function openSlackFieldNoteReviewModal({ teamId, slackUserId, triggerId }) {
Expand Down Expand Up @@ -1623,7 +1689,7 @@ export function slackRoomNeedsNamePayload() {
});
}

export function slackRoomCreatedPayload({ space, openUrl, roomUrl, teamReadsUrl, creatorLinked, pinned }) {
export function slackRoomCreatedPayload({ space, openUrl, roomUrl, teamReadsUrl, teamReadsChannelName, creatorLinked, pinned }) {
const status = creatorLinked
? "linked to your Mumbl login and pinned in Slack."
: "created from Slack. Connect once to claim creator access in Mumbl.";
Expand All @@ -1637,15 +1703,15 @@ export function slackRoomCreatedPayload({ space, openUrl, roomUrl, teamReadsUrl,
{ text: "share with team", actionId: "share_room_invite", value: JSON.stringify({ roomUrl, spaceName: space.name }) },
]),
context(
`Share this with your team so they can join in one step:\n\`${slackJoinCommand(roomUrl)}\`\n${
`${teamReadsChannelName ? `📣 published reads will post to *#${escapeSlackText(teamReadsChannelName)}*.\n` : ""}Share this with your team so they can join in one step:\n\`${slackJoinCommand(roomUrl)}\`\n${
pinned ? "Pinned for publishing." : "Pin this room in Mumbl App Home, or run `/mumbl pin` with the invite link."
}`,
),
],
});
}

export function slackRoomCreatedModalView({ space, openUrl, roomUrl, teamReadsUrl, creatorLinked, pinned }) {
export function slackRoomCreatedModalView({ space, openUrl, roomUrl, teamReadsUrl, teamReadsChannelName, creatorLinked, pinned }) {
return {
type: "modal",
title: { type: "plain_text", text: "room created" },
Expand All @@ -1662,7 +1728,7 @@ export function slackRoomCreatedModalView({ space, openUrl, roomUrl, teamReadsUr
{ text: "share with team", actionId: "share_room_invite", value: JSON.stringify({ roomUrl, spaceName: space.name }) },
]),
context(
`Share with your team so they join in one step:\n\`${slackJoinCommand(roomUrl)}\`\n${
`${teamReadsChannelName ? `📣 published reads will post to *#${escapeSlackText(teamReadsChannelName)}*.\n` : ""}Share with your team so they join in one step:\n\`${slackJoinCommand(roomUrl)}\`\n${
pinned
? "The Slack reads channel only mirrors published team reads."
: "After connecting, Mumbl can pin this space for publishing from Slack."
Expand Down Expand Up @@ -2334,7 +2400,7 @@ function slackPinnedSpacesEmptyModal({ connected }) {
};
}

async function slackPinnedSpacesModal({ pinnedSpaces, notice = "" }) {
async function slackPinnedSpacesModal({ pinnedSpaces, teamId = "", notice = "" }) {
const blocks = [
section("*your pinned teamspaces*\nThese are personal Slack publish destinations. Unpinning only removes your shortcut."),
];
Expand Down Expand Up @@ -2362,7 +2428,7 @@ async function slackPinnedSpacesModal({ pinnedSpaces, notice = "" }) {
const teamReadsSetup = await createTeamReadsSetup({ spaceId: space.id });
rowActions.push({
text: "create reads channel",
url: slackTeamReadsInstallUrl(teamReadsSetup),
url: slackTeamReadsInstallUrl({ ...teamReadsSetup, teamId }),
style: "primary",
});
} catch (error) {
Expand Down
Loading