Download superlog/apps/api/src/slack.ts from smlflg/Experimente: direct link, hf CLI and curl.
- Browser
- Download file 40.1 kB
-
https://huggingface.co/smlflg/Experimente/resolve/main/superlog/apps/api/src/slack.ts
- Command line
-
hf download hf://smlflg/Experimente/superlog/apps/api/src/slack.ts
-
curl -L -o slack.ts https://huggingface.co/smlflg/Experimente/resolve/main/superlog/apps/api/src/slack.ts
40.1 kB
| import crypto from "node:crypto"; | |
| import { | |
| confirmResolutionProposal, | |
| db, | |
| dismissResolutionProposal, | |
| resolveIncident, | |
| schema, | |
| syncLoopsContactsForOrg, | |
| } from "@superlog/db"; | |
| import { and, desc, eq, isNull, sql } from "drizzle-orm"; | |
| import type { Hono } from "hono"; | |
| import type { Context } from "hono"; | |
| import { HTTPException } from "hono/http-exception"; | |
| import { recordFeedback } from "./feedback.js"; | |
| import { getDeviceFlow, getSkillDeviceForIntegration } from "./gateway.js"; | |
| import { closeAgentPullRequestOnGithub } from "./github.js"; | |
| import { runResolvedIncidentSideEffectsForIncident } from "./incidents/resolution-side-effects.js"; | |
| import { logger } from "./logger.js"; | |
| import { resolveActiveOrgContext } from "./org-context.js"; | |
| const log = logger.child({ scope: "slack" }); | |
| const SCOPES = | |
| "chat:write,chat:write.public,channels:read,groups:read,channels:history,groups:history"; | |
| type Vars = { userId: string; orgId: string | null }; | |
| export type SlackResolveClickDisposition = "resolve" | "refresh_side_effects"; | |
| export function resolveSlackResolveClickDisposition(status: string): SlackResolveClickDisposition { | |
| return status === "open" ? "resolve" : "refresh_side_effects"; | |
| } | |
| // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance. | |
| export function mountSlackPublic(app: Hono<any>): void { | |
| const clientId = process.env.SLACK_CLIENT_ID; | |
| const clientSecret = process.env.SLACK_CLIENT_SECRET; | |
| const signingSecret = process.env.SLACK_SIGNING_SECRET; | |
| const redirectUrl = | |
| process.env.SLACK_OAUTH_REDIRECT_URL ?? "http://localhost:4100/slack/oauth/callback"; | |
| const stateSecret = process.env.STATE_SIGNING_SECRET; | |
| const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173"; | |
| if (!clientId || !clientSecret) { | |
| log.warn("SLACK_CLIENT_ID/SECRET not set — /slack/oauth/callback disabled"); | |
| } | |
| // Public Slack-install kickoff for the agent skill: skill receives the | |
| // user_code from the device flow and opens this URL in the user's browser | |
| // post-pairing. We look up the org from the user_code, sign cli-kind state, | |
| // and redirect to Slack's OAuth. Mirrors `/github/install?user_code=…`. | |
| app.get("/slack/install", (c) => { | |
| if (!clientId || !stateSecret) { | |
| return c.json({ error: "slack not configured" }, 503); | |
| } | |
| const callbackRedirectUrl = resolveSlackRedirectUrl(c, redirectUrl); | |
| const userCode = (c.req.query("user_code") ?? "").toUpperCase(); | |
| const device = getSkillDeviceForIntegration(userCode); | |
| if (!device) return c.json({ error: "unknown or not-ready device code" }, 404); | |
| const state = signState( | |
| { orgId: device.orgId, projectId: device.projectId, userId: null, userCode }, | |
| stateSecret, | |
| ); | |
| const url = new URL("https://slack.com/oauth/v2/authorize"); | |
| url.searchParams.set("client_id", clientId); | |
| url.searchParams.set("scope", SCOPES); | |
| url.searchParams.set("redirect_uri", callbackRedirectUrl); | |
| url.searchParams.set("state", state); | |
| return c.redirect(url.toString(), 302); | |
| }); | |
| app.get("/slack/oauth/callback", async (c) => { | |
| if (!clientId || !clientSecret || !stateSecret) { | |
| return c.json({ error: "slack not configured" }, 503); | |
| } | |
| const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin); | |
| const callbackRedirectUrl = resolveSlackRedirectUrl(c, redirectUrl); | |
| const host = c.req.header("host") ?? null; | |
| const err = c.req.query("error"); | |
| if (err) { | |
| log.warn({ error: err, host }, "slack oauth callback denied at slack"); | |
| return c.redirect(`${callbackWebOrigin}/?slack=denied`, 302); | |
| } | |
| const code = c.req.query("code"); | |
| const state = c.req.query("state") ?? ""; | |
| if (!code) { | |
| log.warn({ host }, "slack oauth callback missing code"); | |
| return c.redirect(`${callbackWebOrigin}/?slack=error`, 302); | |
| } | |
| const decoded = verifyState(state, stateSecret); | |
| if (!decoded) { | |
| // State failed HMAC verification or aged past its 10-minute TTL — the | |
| // latter is what a user hits when they linger on Slack's consent screen | |
| // (e.g. waiting on workspace-admin approval) and then return. Bounce them | |
| // back to the app with a retryable error instead of dead-ending on a bare | |
| // JSON 400, and log it so connect drop-offs are diagnosable. | |
| log.warn({ host }, "slack oauth callback rejected: invalid or expired state"); | |
| return c.redirect(`${callbackWebOrigin}/?slack=error`, 302); | |
| } | |
| const orgId = decoded.orgId; | |
| const projectId = decoded.projectId; | |
| log.info( | |
| { org_id: orgId, project_id: projectId, host: c.req.header("host") ?? null }, | |
| "slack oauth callback received", | |
| ); | |
| const res = await fetch("https://slack.com/api/oauth.v2.access", { | |
| method: "POST", | |
| headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| body: new URLSearchParams({ | |
| client_id: clientId, | |
| client_secret: clientSecret, | |
| code, | |
| redirect_uri: callbackRedirectUrl, | |
| }), | |
| }); | |
| const data = (await res.json()) as SlackOAuthResponse; | |
| if (!data.ok || !data.access_token || !data.team?.id) { | |
| log.error({ error: data.error ?? "no_access_token" }, "oauth exchange failed"); | |
| return c.redirect(`${callbackWebOrigin}/?slack=error`, 302); | |
| } | |
| await upsertInstallation({ | |
| projectId, | |
| teamId: data.team.id, | |
| teamName: data.team.name ?? null, | |
| botUserId: data.bot_user_id ?? null, | |
| botAccessToken: data.access_token, | |
| scope: data.scope ?? null, | |
| installedByUserId: decoded.userId, | |
| }); | |
| log.info( | |
| { | |
| org_id: orgId, | |
| project_id: projectId, | |
| team_id: data.team.id, | |
| team_name: data.team.name ?? null, | |
| installed_by: decoded.userId, | |
| }, | |
| "slack installed", | |
| ); | |
| void syncLoopsContactsForOrg({ orgId, appUrl: webOrigin }).catch((err) => { | |
| log.warn({ err, org_id: orgId }, "loops contact sync failed after slack connect"); | |
| }); | |
| // Skill-driven install: bounce the user back to /activate so they see a | |
| // consistent "you're connected" page tied to the agent flow they came | |
| // from. CLI/dashboard-driven install lands on the dashboard as before. | |
| if (decoded.userCode) { | |
| const flow = getDeviceFlow(decoded.userCode); | |
| const flowQuery = flow === "skill" ? "&flow=skill" : ""; | |
| return c.redirect( | |
| `${callbackWebOrigin}/activate?code=${decoded.userCode}${flowQuery}&slack=done`, | |
| 302, | |
| ); | |
| } | |
| return c.redirect(`${callbackWebOrigin}/?slack=installed`, 302); | |
| }); | |
| app.post("/slack/events", async (c) => { | |
| if (!signingSecret) return c.json({ error: "slack signing secret not configured" }, 503); | |
| const rawBody = await c.req.text(); | |
| if (!verifySlackSignature(c, signingSecret, rawBody)) { | |
| log.warn({ path: "/slack/events" }, "slack signature verification failed"); | |
| return c.json({ error: "invalid slack signature" }, 401); | |
| } | |
| const payload = JSON.parse(rawBody) as SlackEventEnvelope; | |
| if (payload.type === "url_verification" && typeof payload.challenge === "string") { | |
| return c.json({ challenge: payload.challenge }); | |
| } | |
| if (payload.type !== "event_callback") return c.json({ ok: true }); | |
| try { | |
| await handleSlackEventEnvelope(payload); | |
| } catch (err) { | |
| log.error( | |
| { err, event_type: payload.event?.type, event_id: payload.event_id }, | |
| "slack event handler failed", | |
| ); | |
| throw err; | |
| } | |
| return c.json({ ok: true }); | |
| }); | |
| app.post("/slack/interactivity", async (c) => { | |
| if (!signingSecret) return c.json({ error: "slack signing secret not configured" }, 503); | |
| const rawBody = await c.req.text(); | |
| if (!verifySlackSignature(c, signingSecret, rawBody)) { | |
| return c.json({ error: "invalid slack signature" }, 401); | |
| } | |
| const form = new URLSearchParams(rawBody); | |
| const payloadRaw = form.get("payload"); | |
| if (!payloadRaw) return c.json({ ok: true }); | |
| const payload = JSON.parse(payloadRaw) as SlackInteractivityPayload; | |
| try { | |
| if (payload.type === "block_actions") { | |
| await handleSlackBlockActions(payload); | |
| } else if (payload.type === "view_submission") { | |
| await handleSlackViewSubmission(payload); | |
| } | |
| } catch (err) { | |
| log.error({ err, type: payload.type }, "slack interactivity handler failed"); | |
| } | |
| // view_submission has a strict ack contract: to close the modal, respond | |
| // HTTP 200 with an EMPTY body. Any non-empty body that isn't a recognized | |
| // `response_action` makes Slack surface "We had some trouble connecting. | |
| // Try again?" and leave the modal open — which is exactly what broke the | |
| // incident feedback modal's Send step (we were returning `{"ok":true}`). | |
| // block_actions has no such constraint (Slack ignores the ack body), so an | |
| // empty 200 is a valid ack there too — return one for every interactivity | |
| // type to stay on the safe side of the contract. | |
| return c.body(null, 200); | |
| }); | |
| } | |
| // Block_actions arrive when someone clicks a non-URL button in a message. | |
| // Recognized action_ids (all encoded by the worker's incidentBlocks builder | |
| // or the sweep proposal posting): | |
| // - `give_feedback:<incident-uuid>` → opens a feedback modal | |
| // - `resolve_incident:<incident-uuid>` → flips the incident to resolved | |
| // attributed to the Slack user who clicked | |
| async function handleSlackBlockActions(payload: SlackInteractivityPayload): Promise<void> { | |
| const action = payload.actions?.[0]; | |
| if (!action) return; | |
| const actionId = action.action_id ?? ""; | |
| if (actionId.startsWith("resolve_incident:")) { | |
| const incidentId = actionId.slice("resolve_incident:".length); | |
| if (incidentId) await handleSlackResolveIncident(incidentId, payload); | |
| return; | |
| } | |
| if (actionId.startsWith("resolve_proposal_confirm:")) { | |
| const proposalId = actionId.slice("resolve_proposal_confirm:".length); | |
| if (proposalId) await handleProposalDecision(proposalId, "confirm", payload); | |
| return; | |
| } | |
| if (actionId.startsWith("resolve_proposal_dismiss:")) { | |
| const proposalId = actionId.slice("resolve_proposal_dismiss:".length); | |
| if (proposalId) await handleProposalDecision(proposalId, "dismiss", payload); | |
| return; | |
| } | |
| if (!actionId.startsWith("give_feedback:")) return; | |
| const incidentId = actionId.slice("give_feedback:".length); | |
| if (!incidentId) return; | |
| const incident = await db.query.incidents.findFirst({ | |
| where: eq(schema.incidents.id, incidentId), | |
| }); | |
| if (!incident) { | |
| log.warn({ incidentId }, "give_feedback click for unknown incident"); | |
| return; | |
| } | |
| const installation = await installationForIncident({ | |
| pinnedId: incident.slackInstallationId, | |
| teamId: payload.team?.id ?? "", | |
| }); | |
| if (!installation) { | |
| log.warn({ team_id: payload.team?.id, incidentId }, "no installation for feedback modal"); | |
| return; | |
| } | |
| const view = { | |
| type: "modal", | |
| callback_id: `feedback_modal:${incidentId}`, | |
| title: { type: "plain_text", text: "Send feedback" }, | |
| submit: { type: "plain_text", text: "Send" }, | |
| close: { type: "plain_text", text: "Cancel" }, | |
| blocks: [ | |
| { | |
| type: "section", | |
| text: { | |
| type: "mrkdwn", | |
| text: `*Feedback on incident:*\n_${truncateModalText(incident.title)}_\nGoes straight to the Superlog team.`, | |
| }, | |
| }, | |
| { | |
| type: "input", | |
| block_id: "feedback_body", | |
| label: { type: "plain_text", text: "What's on your mind?" }, | |
| element: { | |
| type: "plain_text_input", | |
| action_id: "value", | |
| multiline: true, | |
| max_length: 3000, | |
| placeholder: { | |
| type: "plain_text", | |
| text: "What worked, what didn't, what's missing…", | |
| }, | |
| }, | |
| }, | |
| ], | |
| }; | |
| const res = await fetch("https://slack.com/api/views.open", { | |
| method: "POST", | |
| headers: { | |
| "content-type": "application/json; charset=utf-8", | |
| authorization: `Bearer ${installation.botAccessToken}`, | |
| }, | |
| body: JSON.stringify({ trigger_id: payload.trigger_id, view }), | |
| }); | |
| const data = (await res.json()) as { ok: boolean; error?: string }; | |
| if (!data.ok) { | |
| log.warn({ error: data.error, incidentId }, "views.open failed for feedback modal"); | |
| } | |
| } | |
| // Handle a `resolve_incident:<incidentId>` block_actions click. The Slack | |
| // button has a confirm dialog client-side, so by the time we see this the | |
| // user has already double-confirmed. Path: | |
| // 1. Look up the incident + the Slack installation for the team | |
| // 2. Call resolveIncident() — idempotent against concurrent clicks | |
| // 3. Post a threaded "Resolved by @user" reply for an audit trail visible | |
| // in Slack (the row in `incidents.resolved_*` is the structured truth) | |
| // 4. Update the root message in-place so the status badge reflects closure | |
| // and the Resolve button disappears | |
| // | |
| // All Slack side effects are best-effort: a chat.postMessage / chat.update | |
| // failure (channel archived, bot kicked, etc.) doesn't unwind the DB resolve. | |
| // The user can always reopen via recurrence; what we don't want is the DB | |
| // saying open while Slack thinks resolved (or vice-versa) on a transient | |
| // failure. | |
| async function handleSlackResolveIncident( | |
| incidentId: string, | |
| payload: SlackInteractivityPayload, | |
| ): Promise<void> { | |
| const incident = await db.query.incidents.findFirst({ | |
| where: eq(schema.incidents.id, incidentId), | |
| }); | |
| if (!incident) { | |
| log.warn({ incidentId }, "resolve_incident click for unknown incident"); | |
| return; | |
| } | |
| if (resolveSlackResolveClickDisposition(incident.status) === "refresh_side_effects") { | |
| log.info( | |
| { incidentId, status: incident.status }, | |
| "resolve_incident click on already-closed incident, refreshing side effects", | |
| ); | |
| await runSlackResolvedIncidentSideEffects(incidentId); | |
| return; | |
| } | |
| const slackUserId = payload.user?.id ?? null; | |
| const slackUserName = payload.user?.name ?? null; | |
| const attribution = slackUserId ? `<@${slackUserId}>` : (slackUserName ?? "a teammate"); | |
| const { resolved } = await resolveIncident({ | |
| incidentId, | |
| kind: "slack_manual", | |
| reasonCode: "slack_manual", | |
| reasonText: `Resolved from Slack by ${slackUserName ?? slackUserId ?? "unknown user"}.`, | |
| resolvedBySlackUserId: slackUserId, | |
| // No investigation context here — the incident may or may not have one; | |
| // the resolved_* columns on incidents are the audit-of-record for manual | |
| // resolves. Skip the investigation event to avoid coupling to a possibly- | |
| // unrelated latest investigation. | |
| }); | |
| if (!resolved) { | |
| log.info({ incidentId }, "resolve_incident click lost race with concurrent close"); | |
| return; | |
| } | |
| await runSlackResolvedIncidentSideEffects(incidentId); | |
| const installation = await installationForIncident({ | |
| pinnedId: incident.slackInstallationId, | |
| teamId: payload.team?.id ?? "", | |
| }); | |
| if (!installation) { | |
| log.warn({ team_id: payload.team?.id, incidentId }, "no installation to post resolve reply"); | |
| return; | |
| } | |
| if (incident.slackChannelId && incident.slackThreadTs) { | |
| await postSlackThreadReply({ | |
| botToken: installation.botAccessToken, | |
| channel: incident.slackChannelId, | |
| threadTs: incident.slackThreadTs, | |
| text: `:white_check_mark: Incident resolved by ${attribution}. If the underlying error reappears it will re-open automatically.`, | |
| }); | |
| } | |
| } | |
| async function runSlackResolvedIncidentSideEffects(incidentId: string): Promise<void> { | |
| await runResolvedIncidentSideEffectsForIncident({ | |
| incidentId, | |
| closePullRequest: (pr) => | |
| closeAgentPullRequestOnGithub({ | |
| installationId: pr.githubInstallationId, | |
| repoFullName: pr.repoFullName, | |
| prNumber: pr.prNumber, | |
| }), | |
| }); | |
| } | |
| // Confirm / Dismiss buttons on a sweep-agent resolution proposal posted | |
| // into the incident's Slack thread. The proposal row is the audit record; | |
| // confirming additionally closes the incident via resolveIncident(). | |
| // We always edit the proposal message in place so the buttons disappear | |
| // and the surfaced text reflects the decision — clicking the same button | |
| // twice (or the other after a decision) becomes a visible no-op. | |
| async function handleProposalDecision( | |
| proposalId: string, | |
| decision: "confirm" | "dismiss", | |
| payload: SlackInteractivityPayload, | |
| ): Promise<void> { | |
| const slackUserId = payload.user?.id ?? null; | |
| const slackUserName = payload.user?.name ?? null; | |
| const actor = { slackUserId, displayName: slackUserName }; | |
| const result = | |
| decision === "confirm" | |
| ? await confirmResolutionProposal({ proposalId, actor }) | |
| : await dismissResolutionProposal({ proposalId, actor }); | |
| if (!result.ok) { | |
| // Decision rejected (race with another click, unknown id, already | |
| // decided). Stop here so we don't overwrite the Slack message with a | |
| // status that doesn't match the actual proposal state. | |
| log.info( | |
| { proposalId, decision, reason: result.reason }, | |
| "proposal decision rejected (race or unknown id)", | |
| ); | |
| return; | |
| } | |
| if (decision === "confirm" && result.incidentId) { | |
| await runResolvedIncidentSideEffectsForIncident({ | |
| incidentId: result.incidentId, | |
| closePullRequest: (pr) => | |
| closeAgentPullRequestOnGithub({ | |
| installationId: pr.githubInstallationId, | |
| repoFullName: pr.repoFullName, | |
| prNumber: pr.prNumber, | |
| }), | |
| }); | |
| } | |
| // Re-render the proposal message: drop the buttons, swap in a status line | |
| // crediting the deciding user. The message lives in the incident thread, | |
| // so this update is non-destructive — the original incident root | |
| // message and earlier thread activity are untouched. | |
| const proposal = await db.query.incidentResolutionProposals.findFirst({ | |
| where: eq(schema.incidentResolutionProposals.id, proposalId), | |
| }); | |
| if (!proposal?.slackChannelId || !proposal.slackMessageTs) return; | |
| const installation = await installationForIncident({ | |
| pinnedId: proposal.slackInstallationId, | |
| teamId: payload.team?.id ?? "", | |
| }); | |
| if (!installation) return; | |
| const attribution = slackUserId ? `<@${slackUserId}>` : (slackUserName ?? "a teammate"); | |
| const headerEmoji = decision === "confirm" ? ":white_check_mark:" : ":x:"; | |
| const headerText = | |
| decision === "confirm" | |
| ? `${headerEmoji} *Resolution confirmed* by ${attribution}` | |
| : `${headerEmoji} *Proposal dismissed* by ${attribution}`; | |
| const footer = | |
| decision === "confirm" | |
| ? "Incident closed. If the underlying error recurs it will reopen automatically." | |
| : "Incident left open. We won't propose resolution again for 24h."; | |
| const updatedBlocks: unknown[] = [ | |
| { | |
| type: "section", | |
| text: { | |
| type: "mrkdwn", | |
| text: [ | |
| headerText, | |
| proposal.proposedReasonText, | |
| `_Reason: \`${proposal.proposedReasonCode}\`_`, | |
| footer, | |
| ].join("\n"), | |
| }, | |
| }, | |
| ]; | |
| try { | |
| await fetch("https://slack.com/api/chat.update", { | |
| method: "POST", | |
| headers: { | |
| "content-type": "application/json; charset=utf-8", | |
| authorization: `Bearer ${installation.botAccessToken}`, | |
| }, | |
| body: JSON.stringify({ | |
| channel: proposal.slackChannelId, | |
| ts: proposal.slackMessageTs, | |
| text: `${headerText} — ${proposal.proposedReasonText}`, | |
| blocks: updatedBlocks, | |
| }), | |
| }); | |
| } catch (err) { | |
| log.warn({ err, proposalId }, "proposal message re-render failed"); | |
| } | |
| } | |
| async function postSlackThreadReply(opts: { | |
| botToken: string; | |
| channel: string; | |
| threadTs: string; | |
| text: string; | |
| }): Promise<void> { | |
| try { | |
| const res = await fetch("https://slack.com/api/chat.postMessage", { | |
| method: "POST", | |
| headers: { | |
| "content-type": "application/json; charset=utf-8", | |
| authorization: `Bearer ${opts.botToken}`, | |
| }, | |
| body: JSON.stringify({ | |
| channel: opts.channel, | |
| thread_ts: opts.threadTs, | |
| text: opts.text, | |
| }), | |
| }); | |
| const data = (await res.json()) as { ok: boolean; error?: string }; | |
| if (!data.ok) { | |
| log.warn( | |
| { error: data.error, channel: opts.channel, thread_ts: opts.threadTs }, | |
| "thread reply post failed", | |
| ); | |
| } | |
| } catch (err) { | |
| log.warn({ err, channel: opts.channel }, "chat.postMessage threw"); | |
| } | |
| } | |
| async function handleSlackViewSubmission(payload: SlackInteractivityPayload): Promise<void> { | |
| const callbackId = payload.view?.callback_id ?? ""; | |
| if (!callbackId.startsWith("feedback_modal:")) return; | |
| const incidentId = callbackId.slice("feedback_modal:".length); | |
| if (!incidentId) return; | |
| const body = payload.view?.state?.values?.feedback_body?.value?.value?.trim() ?? ""; | |
| if (!body) return; | |
| const incident = await db.query.incidents.findFirst({ | |
| where: eq(schema.incidents.id, incidentId), | |
| }); | |
| const project = incident | |
| ? await db.query.projects.findFirst({ | |
| where: eq(schema.projects.id, incident.projectId), | |
| }) | |
| : null; | |
| await recordFeedback({ | |
| kind: "incident", | |
| refId: incidentId, | |
| refRepo: null, | |
| source: "slack_button", | |
| body, | |
| authorUserId: null, | |
| authorExternal: { | |
| slackUserId: payload.user?.id, | |
| slackTeamId: payload.team?.id, | |
| }, | |
| orgId: project?.orgId ?? null, | |
| projectId: project?.id ?? null, | |
| }); | |
| } | |
| async function findInstallationForTeam(teamId: string) { | |
| if (!teamId) return null; | |
| return db.query.slackInstallations.findFirst({ | |
| where: and( | |
| eq(schema.slackInstallations.teamId, teamId), | |
| isNull(schema.slackInstallations.revokedAt), | |
| ), | |
| // When a team owns multiple non-revoked rows (the same workspace installed | |
| // into several projects) Slack keeps only the most-recently-minted bot | |
| // token live, so order by token-refresh recency — `installedAt`, which is | |
| // set on every (re)auth, NOT `createdAt`, which the in-place token refresh | |
| // leaves stale. Legacy rows predating `installedAt` are NULL, so fall back | |
| // to `createdAt` for them via coalesce rather than letting NULLs sort last. | |
| // Still best-effort: for incident-scoped actions prefer | |
| // installationForIncident, which uses the exact pinned installation. This | |
| // team lookup is only the legacy/unpinned fallback. | |
| orderBy: desc( | |
| sql`coalesce(${schema.slackInstallations.installedAt}, ${schema.slackInstallations.createdAt})`, | |
| ), | |
| }); | |
| } | |
| // Apply the installation-selection precedence for incident-scoped Slack | |
| // interactions: the installation pinned to the incident/proposal — the exact | |
| // workspace + bot token that posted the thread — wins over any team-wide match. | |
| // | |
| // Why this matters: a workspace can be installed into more than one project, | |
| // and `upsertInstallation` keys rows by project (unique on project_id+team_id), | |
| // so one Slack team can own several non-revoked `slack_installations` rows. | |
| // Slack issues a fresh bot token on each (re)install and invalidates the prior | |
| // token, so only one of those rows holds a live token at a time. A team-wide | |
| // `findFirst` can therefore return a stale row whose token fails every Slack | |
| // API call with `invalid_auth` — which is exactly what silently broke the | |
| // incident feedback modal (views.open -> invalid_auth, so the modal never | |
| // opened and the click looked like a no-op). The pin is exact, so honour it | |
| // first; the team match is only a fallback for legacy rows written before the | |
| // pin existed. | |
| export function preferPinnedInstallation<T>( | |
| pinned: T | null | undefined, | |
| teamFallback: T | null | undefined, | |
| ): T | null { | |
| return pinned ?? teamFallback ?? null; | |
| } | |
| // Resolve the Slack installation to act through for an incident or proposal, | |
| // preferring its pinned installation id (see preferPinnedInstallation). The | |
| // team lookup only runs when there is no usable pin. | |
| async function installationForIncident(opts: { pinnedId: string | null; teamId: string }) { | |
| const pinned = opts.pinnedId | |
| ? await db.query.slackInstallations.findFirst({ | |
| where: and( | |
| eq(schema.slackInstallations.id, opts.pinnedId), | |
| isNull(schema.slackInstallations.revokedAt), | |
| ), | |
| }) | |
| : null; | |
| return preferPinnedInstallation( | |
| pinned, | |
| pinned ? null : await findInstallationForTeam(opts.teamId), | |
| ); | |
| } | |
| function truncateModalText(text: string): string { | |
| return text.length > 200 ? `${text.slice(0, 197)}…` : text; | |
| } | |
| // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance. | |
| export function mountSlackAuthed(app: Hono<any>): void { | |
| const clientId = process.env.SLACK_CLIENT_ID; | |
| const redirectUrl = | |
| process.env.SLACK_OAUTH_REDIRECT_URL ?? "http://localhost:4100/slack/oauth/callback"; | |
| const stateSecret = process.env.STATE_SIGNING_SECRET; | |
| app.get("/api/slack/installation", async (c) => { | |
| const ctx = await resolveUserOrg(c); | |
| if (!ctx) return c.json({ installed: false }); | |
| const row = await findInstallation(ctx.projectId); | |
| if (!row) return c.json({ installed: false }); | |
| return c.json({ | |
| installed: true, | |
| teamId: row.teamId, | |
| teamName: row.teamName, | |
| }); | |
| }); | |
| app.post("/api/slack/install-url", async (c) => { | |
| if (!clientId || !stateSecret) { | |
| return c.json({ error: "slack not configured" }, 503); | |
| } | |
| const callbackRedirectUrl = resolveSlackRedirectUrl(c, redirectUrl); | |
| const ctx = await resolveUserOrg(c); | |
| if (!ctx) return c.json({ error: "no org for user" }, 404); | |
| const state = signState( | |
| { orgId: ctx.orgId, projectId: ctx.projectId, userId: ctx.userId }, | |
| stateSecret, | |
| ); | |
| const url = new URL("https://slack.com/oauth/v2/authorize"); | |
| url.searchParams.set("client_id", clientId); | |
| url.searchParams.set("scope", SCOPES); | |
| url.searchParams.set("redirect_uri", callbackRedirectUrl); | |
| url.searchParams.set("state", state); | |
| log.info( | |
| { org_id: ctx.orgId, project_id: ctx.projectId, redirect_uri: callbackRedirectUrl }, | |
| "slack install url created", | |
| ); | |
| return c.json({ url: url.toString() }); | |
| }); | |
| app.post("/api/slack/uninstall", async (c) => { | |
| const ctx = await resolveUserOrg(c); | |
| if (!ctx) return c.json({ error: "no org for user" }, 404); | |
| const row = await findInstallation(ctx.projectId); | |
| if (!row) return c.json({ ok: true }); | |
| // Best-effort remote revoke; don't block on failure. | |
| try { | |
| await fetch("https://slack.com/api/auth.revoke", { | |
| method: "POST", | |
| headers: { authorization: `Bearer ${row.botAccessToken}` }, | |
| }); | |
| } catch (e) { | |
| log.warn({ err: e }, "auth.revoke failed"); | |
| } | |
| await db | |
| .update(schema.slackInstallations) | |
| .set({ revokedAt: new Date() }) | |
| .where(eq(schema.slackInstallations.id, row.id)); | |
| return c.json({ ok: true }); | |
| }); | |
| app.get("/api/slack/channels", async (c) => { | |
| const ctx = await resolveUserOrg(c); | |
| if (!ctx) return c.json({ error: "no org for user" }, 404); | |
| const row = await findInstallation(ctx.projectId); | |
| if (!row) return c.json({ error: "slack not installed" }, 404); | |
| const result = await listSlackChannels(row.botAccessToken); | |
| if (!result.ok) { | |
| log.warn({ team_id: row.teamId, error: result.error }, "slack conversations.list failed"); | |
| if (result.error === "not_authed" || result.error === "token_revoked") { | |
| await db | |
| .update(schema.slackInstallations) | |
| .set({ revokedAt: new Date() }) | |
| .where(eq(schema.slackInstallations.id, row.id)); | |
| } | |
| return c.json({ error: result.error }, 502); | |
| } | |
| return c.json({ channels: result.channels }); | |
| }); | |
| app.get("/api/projects/:projectId/slack-route", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const install = await findInstallation(projectId); | |
| if (!install || !install.channelId) return c.json({ configured: false }); | |
| return c.json({ | |
| configured: true, | |
| channelId: install.channelId, | |
| channelName: install.channelName, | |
| }); | |
| }); | |
| app.put("/api/projects/:projectId/slack-route", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const install = await findInstallation(projectId); | |
| if (!install) return c.json({ error: "slack not installed" }, 400); | |
| const body = (await c.req.json().catch(() => ({}))) as { | |
| channelId?: unknown; | |
| channelName?: unknown; | |
| }; | |
| const channelId = typeof body.channelId === "string" ? body.channelId : ""; | |
| const channelName = typeof body.channelName === "string" ? body.channelName : null; | |
| if (!channelId) return c.json({ error: "channelId required" }, 400); | |
| await db | |
| .update(schema.slackInstallations) | |
| .set({ channelId, channelName }) | |
| .where(eq(schema.slackInstallations.id, install.id)); | |
| return c.json({ ok: true, channelId, channelName }); | |
| }); | |
| app.delete("/api/projects/:projectId/slack-route", async (c) => { | |
| const projectId = c.req.param("projectId"); | |
| await requireProjectAccess(c, projectId); | |
| const install = await findInstallation(projectId); | |
| if (install) { | |
| await db | |
| .update(schema.slackInstallations) | |
| .set({ channelId: null, channelName: null }) | |
| .where(eq(schema.slackInstallations.id, install.id)); | |
| } | |
| return c.json({ ok: true }); | |
| }); | |
| } | |
| async function findInstallation(projectId: string) { | |
| return db.query.slackInstallations.findFirst({ | |
| where: and( | |
| eq(schema.slackInstallations.projectId, projectId), | |
| isNull(schema.slackInstallations.revokedAt), | |
| ), | |
| }); | |
| } | |
| async function upsertInstallation(v: { | |
| projectId: string; | |
| teamId: string; | |
| teamName: string | null; | |
| botUserId: string | null; | |
| botAccessToken: string; | |
| scope: string | null; | |
| installedByUserId: string | null; | |
| }): Promise<void> { | |
| await db | |
| .insert(schema.slackInstallations) | |
| .values({ | |
| projectId: v.projectId, | |
| teamId: v.teamId, | |
| teamName: v.teamName, | |
| botUserId: v.botUserId, | |
| botAccessToken: v.botAccessToken, | |
| scope: v.scope, | |
| installedByUserId: v.installedByUserId, | |
| installedAt: new Date(), | |
| }) | |
| .onConflictDoUpdate({ | |
| target: [schema.slackInstallations.projectId, schema.slackInstallations.teamId], | |
| set: { | |
| teamName: v.teamName, | |
| botUserId: v.botUserId, | |
| botAccessToken: v.botAccessToken, | |
| scope: v.scope, | |
| installedByUserId: v.installedByUserId, | |
| revokedAt: null, | |
| // Reinstall mints a fresh bot token and invalidates the old one, so | |
| // record the refresh time — this is what the team-wide fallback orders | |
| // by to find the row holding the currently-live token. | |
| installedAt: new Date(), | |
| }, | |
| }); | |
| } | |
| async function handleSlackEventEnvelope(payload: SlackEventEnvelope): Promise<void> { | |
| const event = payload.event; | |
| if (!event || event.type !== "message") return; | |
| if (event.subtype || event.bot_id) return; | |
| if (!event.channel || !event.thread_ts || !event.ts || event.thread_ts === event.ts) return; | |
| if (typeof event.text !== "string" || event.text.trim().length === 0) return; | |
| const incident = await db.query.incidents.findFirst({ | |
| where: and( | |
| eq(schema.incidents.slackChannelId, event.channel), | |
| eq(schema.incidents.slackThreadTs, event.thread_ts), | |
| ), | |
| }); | |
| if (!incident) return; | |
| const agentRun = await db.query.agentRuns.findFirst({ | |
| where: eq(schema.agentRuns.incidentId, incident.id), | |
| orderBy: [desc(schema.agentRuns.createdAt)], | |
| }); | |
| if (!agentRun || agentRun.state !== "awaiting_human") return; | |
| await db | |
| .insert(schema.incidentEvents) | |
| .values({ | |
| agentRunId: agentRun.id, | |
| kind: "human_reply", | |
| summary: event.text.trim(), | |
| detail: { | |
| slackEventId: payload.event_id ?? null, | |
| slackUserId: event.user ?? null, | |
| slackChannelId: event.channel, | |
| slackThreadTs: event.thread_ts, | |
| slackMessageTs: event.ts, | |
| }, | |
| dedupeKey: payload.event_id | |
| ? `slack:${payload.event_id}` | |
| : `slack:${event.channel}:${event.ts}`, | |
| }) | |
| .onConflictDoNothing({ | |
| target: [schema.incidentEvents.agentRunId, schema.incidentEvents.dedupeKey], | |
| }); | |
| } | |
| async function resolveUserOrg( | |
| c: Context<{ Variables: Vars }>, | |
| ): Promise<{ userId: string; orgId: string; projectId: string } | null> { | |
| const userId = c.var.userId; | |
| if (!userId) return null; | |
| const ctx = await resolveActiveOrgContext({ | |
| userId, | |
| preferredOrgId: c.var.orgId, | |
| }).catch(() => null); | |
| if (!ctx) return null; | |
| return { userId: ctx.user.id, orgId: ctx.org.id, projectId: ctx.project.id }; | |
| } | |
| async function requireProjectAccess( | |
| c: Context<{ Variables: Vars }>, | |
| projectId: string, | |
| ): Promise<{ userId: string; orgId: string }> { | |
| const ctx = await resolveUserOrg(c); | |
| if (!ctx) throw new HTTPException(401, { message: "not authenticated" }); | |
| const project = await db.query.projects.findFirst({ | |
| where: eq(schema.projects.id, projectId), | |
| }); | |
| if (!project) throw new HTTPException(404, { message: "project not found" }); | |
| if (project.orgId !== ctx.orgId) throw new HTTPException(403, { message: "forbidden" }); | |
| return ctx; | |
| } | |
| // `userId` is the installer when the install was kicked off from the | |
| // dashboard's authed flow; for the skill kickoff we don't have a signed-in | |
| // user at issue time so it can be null. `userCode` is set only for | |
| // skill-flow installs and is what the callback uses to bounce the user | |
| // back to /activate on completion. | |
| type StatePayload = { | |
| orgId: string; | |
| projectId: string; | |
| userId: string | null; | |
| userCode?: string; | |
| }; | |
| function signState(p: StatePayload, secret: string): string { | |
| const body = `${p.orgId}.${p.projectId}.${p.userId ?? ""}.${p.userCode ?? ""}.${Date.now()}`; | |
| const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url"); | |
| return `${Buffer.from(body, "utf8").toString("base64url")}.${sig}`; | |
| } | |
| function verifyState(state: string, secret: string): StatePayload | null { | |
| const [payloadB64, sig] = state.split("."); | |
| if (!payloadB64 || !sig) return null; | |
| const body = Buffer.from(payloadB64, "base64url").toString("utf8"); | |
| const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url"); | |
| const provided = Buffer.from(sig); | |
| const expectedBuf = Buffer.from(expected); | |
| if (provided.length !== expectedBuf.length) return null; | |
| if (!crypto.timingSafeEqual(provided, expectedBuf)) return null; | |
| const parts = body.split("."); | |
| // Current format is `${orgId}.${projectId}.${userId}.${userCode}.${ts}` (5 | |
| // parts). Older 3- or 4-part states (no projectId) are no longer accepted — | |
| // they expire after 10 min anyway, so the only impact is users who started | |
| // an install flow within ~10 min of the deploy seeing an "invalid state" | |
| // error and having to click install again. | |
| if (parts.length !== 5) return null; | |
| const [orgId, projectId, userId, userCodeRaw, tsRaw] = parts as [ | |
| string, | |
| string, | |
| string, | |
| string, | |
| string, | |
| ]; | |
| if (!orgId || !projectId || !tsRaw) return null; | |
| const ts = Number(tsRaw); | |
| if (!Number.isFinite(ts) || Date.now() - ts > 10 * 60 * 1000) return null; | |
| return { orgId, projectId, userId: userId || null, userCode: userCodeRaw || undefined }; | |
| } | |
| function resolveCallbackWebOrigin(c: Context, configuredWebOrigin: string): string { | |
| const host = c.req.header("host") ?? ""; | |
| if ( | |
| host === "localhost:4100" || | |
| host === "127.0.0.1:4100" || | |
| configuredWebOrigin.endsWith(".superlog.localhost:1355") | |
| ) { | |
| return "http://localhost:5173"; | |
| } | |
| return configuredWebOrigin; | |
| } | |
| function resolveSlackRedirectUrl(c: Context, configuredRedirectUrl: string): string { | |
| const origin = c.req.header("origin") ?? ""; | |
| const host = c.req.header("host") ?? ""; | |
| if ( | |
| origin === "http://localhost:5173" || | |
| origin === "http://127.0.0.1:5173" || | |
| host === "localhost:4100" || | |
| host === "127.0.0.1:4100" | |
| ) { | |
| return "http://localhost:4100/slack/oauth/callback"; | |
| } | |
| return configuredRedirectUrl; | |
| } | |
| function verifySlackSignature(c: Context, signingSecret: string, rawBody: string): boolean { | |
| const signature = c.req.header("x-slack-signature"); | |
| const timestamp = c.req.header("x-slack-request-timestamp"); | |
| if (!signature || !timestamp) return false; | |
| const timestampSeconds = Number(timestamp); | |
| if (!Number.isFinite(timestampSeconds)) return false; | |
| if (Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 60 * 5) return false; | |
| const base = `v0:${timestamp}:${rawBody}`; | |
| const expected = `v0=${crypto.createHmac("sha256", signingSecret).update(base).digest("hex")}`; | |
| const providedBuffer = Buffer.from(signature, "utf8"); | |
| const expectedBuffer = Buffer.from(expected, "utf8"); | |
| if (providedBuffer.length !== expectedBuffer.length) return false; | |
| return crypto.timingSafeEqual(providedBuffer, expectedBuffer); | |
| } | |
| type SlackOAuthResponse = { | |
| ok: boolean; | |
| error?: string; | |
| access_token?: string; | |
| scope?: string; | |
| bot_user_id?: string; | |
| team?: { id?: string; name?: string }; | |
| }; | |
| type SlackConversationsList = { | |
| ok: boolean; | |
| error?: string; | |
| channels?: { id: string; name: string; is_private?: boolean }[]; | |
| response_metadata?: { next_cursor?: string }; | |
| }; | |
| export type SlackChannelSummary = { id: string; name: string; isPrivate: boolean }; | |
| export type ListSlackChannelsResult = | |
| | { ok: true; channels: SlackChannelSummary[] } | |
| | { ok: false; error: string }; | |
| // Slack's conversations.list returns at most `limit` channels per page; a big | |
| // workspace needs cursor pagination or the list silently truncates — and a | |
| // private channel the bot was invited to can fall off the end and "disappear" | |
| // from the dropdown. Walk every page (capped) and aggregate. Note: even with | |
| // groups:read, Slack only returns private channels the bot is a *member* of, | |
| // so the user still has to `/invite` the bot to a private channel for it to | |
| // show up at all. | |
| export async function listSlackChannels( | |
| token: string, | |
| fetchImpl: typeof fetch = fetch, | |
| ): Promise<ListSlackChannelsResult> { | |
| const channels: SlackChannelSummary[] = []; | |
| let cursor: string | undefined; | |
| // Hard page cap (200 * 50 = 10k channels) so a misbehaving cursor can't loop. | |
| for (let page = 0; page < 50; page++) { | |
| const url = new URL("https://slack.com/api/conversations.list"); | |
| url.searchParams.set("types", "public_channel,private_channel"); | |
| url.searchParams.set("exclude_archived", "true"); | |
| url.searchParams.set("limit", "200"); | |
| if (cursor) url.searchParams.set("cursor", cursor); | |
| const res = await fetchImpl(url, { | |
| headers: { authorization: `Bearer ${token}` }, | |
| }); | |
| const data = (await res.json()) as SlackConversationsList; | |
| if (!data.ok) return { ok: false, error: data.error ?? "unknown" }; | |
| for (const ch of data.channels ?? []) { | |
| channels.push({ id: ch.id, name: ch.name, isPrivate: ch.is_private ?? false }); | |
| } | |
| cursor = data.response_metadata?.next_cursor || undefined; | |
| if (!cursor) break; | |
| } | |
| if (cursor) return { ok: false, error: "pagination_limit_exceeded" }; | |
| return { ok: true, channels }; | |
| } | |
| // Slack interactivity envelope. `view.state.values` is keyed by block_id | |
| // then action_id; for the feedback modal that's | |
| // `state.values.feedback_body.value.value` (block_id="feedback_body", | |
| // action_id="value", input type plain_text_input). | |
| type SlackInteractivityPayload = { | |
| type?: string; | |
| trigger_id?: string; | |
| team?: { id?: string }; | |
| user?: { id?: string; name?: string }; | |
| actions?: Array<{ action_id?: string; value?: string }>; | |
| view?: { | |
| callback_id?: string; | |
| state?: { | |
| values?: { | |
| feedback_body?: { | |
| value?: { value?: string }; | |
| }; | |
| }; | |
| }; | |
| }; | |
| }; | |
| type SlackEventEnvelope = { | |
| type?: string; | |
| challenge?: string; | |
| event_id?: string; | |
| event?: { | |
| type?: string; | |
| subtype?: string; | |
| text?: string; | |
| channel?: string; | |
| thread_ts?: string; | |
| ts?: string; | |
| user?: string; | |
| bot_id?: string; | |
| }; | |
| }; | |