Keyrxng

AI Slack Agent for Iffy (Gumroad)

Iffy (Gumroad) · 2024 · Proposed multi-workspace Slack agent with OAuth, encrypted tokens, email-based identity resolution, and tool-gated admin actions (PR not merged due to style differences).

So what?
multi tenant workspaces supported
Role
Full-stack Engineer
Year
2024
Stack
TypeScript, Next.js, Slack Web API, Drizzle ORM, Inngest, Clerk, @47ng/cloak
Read narrative
multi tenant workspaces supported

Problem

Moderators had to context-switch between Slack and the app to suspend/unsuspend users or fetch user info. The system needed a Slack-native agent that could act securely across multiple workspaces.

Approach

System diagram

flowchart LR
  Client[Client] --> SlackOAuth[Slack OAuth]
  SlackOAuth --> DashboardSettings[Dashboard Settings]
  DashboardSettings --> WebhookStorage[Webhook Storage]
  WebhookStorage --> InngestEvent[Inngest Event]
  InngestEvent --> SlackContext[SlackContext]
  SlackContext --> UserVerification[User Verification]
  UserVerification --> ToolAuthorization[Tool Authorization]
  ToolAuthorization --> DatabaseAction[Database Action]
  DatabaseAction --> SlackResponse[Slack Response]

Status and review feedback

“Took a look, I don’t think the code is super inline with the rest of the codebase. Seems more like a Java approach to writing code than TypeScript. The Helper repo / PR around agents, and the Flexile one too, I think has a good approach. Would recommend basing it off of those.” — Sahil Lavingia (CEO)

The implementation favored a class-based context object and TypeScript generics to map event payloads directly into handler context. The existing codebase trends more functional and uses inner method type narrowing checks.

Outcome

Constraints

Design choices

Code excerpt — type-safe payload mapping

export type SupportedSlackEvents = SlackEvent["type"] | "url_verification";

type SlackEventWithUrlVerification = Pick<SlackEvent, "type"> & {
  challenge: string;
  token: string;
  type: "url_verification";
};

export type SlackEventPayload<T extends SupportedSlackEvents> = {
  type: T;
  teamId: string;
  appId: string;
  event: T extends "url_verification"
    ? SlackEventWithUrlVerification
    : Omit<Extract<SlackEvent, { type: T }>, "type">;
};

export type SlackEventCallbacks<T extends SupportedSlackEvents> = {
  [K in T]: Array<(ctx: SlackContext<K>) => Promise<NextResponse>>;
};

Proof

Code excerpt — SlackContext identity resolution

// app/api/v1/slack/agent/context.ts
async getIffyUserFromSlackId(slackUserId: string) {
  const userSlackInfo = await this.client.users.info({ user: slackUserId });
  const organization = await db.query.organizations.findFirst({
    where: eq(schema.organizations.clerkOrganizationId, this.inbox.clerkOrganizationId),
  });

  const userEmail = userSlackInfo.user?.profile?.email;
  if (!userEmail || !organization) {
    return { clerkUserId: null, slackUserId };
  }

  const { data: [clerkAuthedUser] } = await (await clerkClient()).users.getUserList({
    emailAddress: [userEmail],
    limit: 1,
  });
  // ... use clerkAuthedUser for permission checks
}

Code excerpt — OAuth callback (scopes + token exchange)

// app/api/v1/slack/oauth/callback/route.ts
export async function POST(request: NextRequest) {
  const { code, state } = await request.json();
  const { clerkOrganizationId } = JSON.parse(Buffer.from(state, 'base64').toString('utf-8'));
  const oauthResponse = await fetch('https://slack.com/api/oauth.v2.access', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: env.NEXT_PUBLIC_SLACK_CLIENT_ID,
      client_secret: env.SLACK_CLIENT_SECRET,
      code,
    }).toString(),
  });
  // ... persist encrypted tokens bound to clerkOrganizationId
}

QA/PR evidence — integration PR (not merged)

References