From 57e141e7acca49cc2d80fd420829d418e267b4aa Mon Sep 17 00:00:00 2001 From: Disha Agarwalla Date: Sun, 28 Jun 2026 15:31:41 +0530 Subject: [PATCH] Auto-create Slack reads channel when workspace already granted scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuse the workspace's stored elevated bot token to create the reads channel inline on room creation — no second OAuth once a workspace has enabled team reads. Falls back to the one-time upgrade link otherwise. - Defer modal-submission room creation to after() with a loading view so the added Slack API calls can't trip the ~3s view_submission timeout. - Pin the team-reads OAuth consent to the acting workspace via the `team` param, so multi-workspace users don't authorize the wrong one. Co-Authored-By: Claude Opus 4.8 --- app/api/slack/interactions/route.js | 39 ++++++---- src/server/slack.js | 114 ++++++++++++++++++++++------ 2 files changed, 114 insertions(+), 39 deletions(-) diff --git a/app/api/slack/interactions/route.js b/app/api/slack/interactions/route.js index 5d478c9..14ae801 100644 --- a/app/api/slack/interactions/route.js +++ b/app/api/slack/interactions/route.js @@ -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") { diff --git a/src/server/slack.js b/src/server/slack.js index b0dd733..f43838d 100644 --- a/src/server/slack.js +++ b/src/server/slack.js @@ -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, @@ -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()}`; } @@ -224,16 +230,24 @@ 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(); @@ -241,7 +255,7 @@ export async function createSlackSpaceChannel({ oauthResult, setup }) { .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, @@ -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 }); @@ -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 { @@ -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, }; } @@ -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 }) { @@ -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."; @@ -1637,7 +1703,7 @@ 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." }`, ), @@ -1645,7 +1711,7 @@ export function slackRoomCreatedPayload({ space, openUrl, roomUrl, teamReadsUrl, }); } -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" }, @@ -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." @@ -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."), ]; @@ -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) {