import { ensure } from "ensured"; import { Client } from "magicbell-js/user-client"; import { action, createMachine, guard, immediate, invoke, reduce, state, transition } from "robot3"; import { log } from "../lib/log"; import { isOK } from "../lib/response"; import { redirectStorage } from "../stores/redirect-storage"; type Props = { client: Client; appId: string; redirectUrl?: string; }; type Context = { error?: string; }; const clearRedirectStorage = action(() => { redirectStorage.clear(); }); function setErrorMessageOnContext(message?: string) { return reduce((ctx, ev) => ({ ...ctx, error: message || ev.error.message })); } function errorTransition(message?: string) { return transition("error", "error", setErrorMessageOnContext(message), clearRedirectStorage); } function isCallbackUrl(appId: string) { return () => { if (typeof window === "undefined") return false; const params = new URLSearchParams(window.location.search); const stored = redirectStorage.get(); if (!stored) return false; if (stored.channel !== "slack" || stored.id !== appId) return false; if (!params.get("code") || !params.get("state")) return false; return true; }; } type CallbackContext = { code: string; state: string; appId: string; }; export const moveCallbackParamsToContext = reduce((ctx: any) => { const url = new URL(window.location.href); const stored = redirectStorage.get(); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); // cleanup the URL without reload url.searchParams.delete("code"); url.searchParams.delete("state"); window.history.replaceState(null, "", url.toString()); return { ...ctx, code, state, appId: stored?.id }; }); function getInitialContext(): Context { return { error: undefined }; } export function createInstallSlackMachine({ client, appId, redirectUrl }: Props) { return createMachine({ init: state(immediate("finishInstall", guard(isCallbackUrl(appId)), moveCallbackParamsToContext, clearRedirectStorage), immediate("checkTokens")), checkTokens: invoke(log.wrap("debug", "slack.checkTokens", async () => { const tokens = await client.channels.listSlackTokens(); const activeTokens = tokens.data?.data?.filter(x => !x.discardedAt); const appToken = activeTokens?.find(x => x.id.startsWith(`${appId}-`)); return { tokenId: appToken?.id }; }), transition("done", "active", guard((_, e: any) => e.data?.tokenId), reduce((ctx: any, e: any) => ({ ...ctx, tokenId: e.data.tokenId }))), transition("done", "idle"), errorTransition("Failed to fetch Slack tokens")), idle: state(transition("start", "startInstall")), startInstall: invoke(log.wrap("debug", "slack.startInstall", async () => { const { data, metadata: res } = await client.integrations.startSlackInstallation({ appId: appId, redirectUrl: redirectUrl }); ensure(isOK(res), `Failed to start Slack installation: ${res.status} ${res.statusText}`); ensure(data?.authUrl, "Unexpected server response, missing authUrl"); return { authUrl: data.authUrl }; }), transition("done", "navigate"), errorTransition("Failed to start Slack installation")), navigate: invoke(log.wrap("debug", "slack.navigate", async (_, e) => { const authUrl = e.data.authUrl; ensure(authUrl, "Slack authorization URL missing"); redirectStorage.set("slack", appId); window.open(authUrl, "_self"); }), transition("done", "waitForCallback"), errorTransition("Failed to navigate to Slack")), // final state, as we're leaving the page waitForCallback: state(), finishInstall: invoke(log.wrap("debug", "slack.finishInstall", async ctx => { const { data, metadata: res } = await client.integrations.finishSlackInstallation({ code: ctx.code, appId: ctx.appId, redirectUrl: redirectUrl }); ensure(isOK(res), `Failed to finish Slack installation: ${res.status} ${res.statusText}`); ensure(data?.id, "Unexpected server response, missing id"); return { installation: data }; }), transition("done", "saveToken"), errorTransition("Failed to finish Slack installation")), saveToken: invoke(log.wrap("debug", "slack.saveToken", async (_, e) => { const installation = e.data.installation; const { data, metadata: res } = await client.channels.saveSlackToken({ oauth: { channelId: installation.authedUser.id, installationId: installation.id } }); ensure(isOK(res), `Failed to save Slack token: ${res.status} ${res.statusText}`); ensure(data?.oauth, "Unexpected server response, missing oauth"); }), transition("done", "checkTokens"), errorTransition("Failed to save Slack token")), removeToken: invoke(log.wrap("debug", "slack.removeToken", async (ctx: any) => { const { metadata: res } = await client.channels.deleteSlackToken(ctx.tokenId); ensure(isOK(res), `Failed to discard Slack token: ${res.status} ${res.statusText}`); }), transition("done", "idle"), errorTransition("Failed to discard Slack token")), error: state(transition("start", "startInstall")), active: state(transition("discard", "removeToken")) }, getInitialContext); }