diff --git a/packages/console/app/package.json b/packages/console/app/package.json index cb0f91a64f..e3d8c5ad1a 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -11,6 +11,7 @@ "start": "vite start" }, "dependencies": { + "@ai-sdk/openai": "2.0.89", "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", "@jsx-email/render": "1.1.1", @@ -26,6 +27,7 @@ "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", "@stripe/stripe-js": "8.6.1", + "ai": "catalog:", "chart.js": "4.5.1", "nitro": "3.0.1-alpha.1", "solid-js": "catalog:", diff --git a/packages/console/app/src/lib/enterprise.ts b/packages/console/app/src/lib/enterprise.ts new file mode 100644 index 0000000000..36d73d30d2 --- /dev/null +++ b/packages/console/app/src/lib/enterprise.ts @@ -0,0 +1,309 @@ +import { createOpenAI } from "@ai-sdk/openai" +import { AWS } from "@opencode-ai/console-core/aws.js" +import { generateObject } from "ai" +import { z } from "zod" +import { createLead } from "./salesforce" + +const links = [ + { label: "Docs", url: "https://opencode.ai/docs" }, + { label: "Discord Community", url: "https://discord.gg/scN9YX6Fdd" }, + { label: "GitHub", url: "https://github.com/anomalyco/opencode" }, +] + +const from = "Stefan " +const sign = "Stefan" + +const shape = z.object({ + company: z.string().nullable().describe("Company name. Use null when unknown."), + size: z + .enum(["1-50", "51-250", "251-1000", "1001+"]) + .nullable() + .describe("Company size bucket. Use null when unknown."), + first: z.string().nullable().describe("First name only. Use null when unknown."), + title: z.string().nullable().describe("Job title or role. Use null when unknown."), + seats: z.number().int().positive().nullable().describe("Approximate seat count. Use null when unknown."), + procurement: z + .boolean() + .describe("True when the inquiry is blocked on procurement, legal, vendor, security, or compliance review."), + effort: z + .enum(["low", "medium", "high"]) + .describe("Lead quality based on how specific and commercially relevant the inquiry is."), + summary: z.string().nullable().describe("One sentence summary for the sales team. Use null when unnecessary."), +}) + +const system = [ + "You triage inbound enterprise inquiries for OpenCode.", + "Extract the fields from the form data and message.", + "Do not invent facts. Use null when a field is unknown.", + "First name should only contain the given name.", + "Seats should only be set when the inquiry mentions or strongly implies a team, user, developer, or seat count.", + "Procurement should be true when the inquiry mentions approval, review, legal, vendor, security, or compliance processes.", + "Effort is low for vague or generic inquiries, medium for some business context, and high for strong buying intent, rollout scope, or blockers.", +].join("\n") + +export interface Inquiry { + name: string + role: string + company?: string + email: string + phone?: string + alias?: string + message: string +} + +export type Score = z.infer + +type Kind = "generic" | "procurement" +type Mail = { + subject: string + text: string + html: string +} + +function field(text?: string | null) { + const value = text?.trim() + if (!value) return null + return value +} + +function safe(text: string) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'") +} + +function html(text: string) { + return safe(text).replace(/\n/g, "
") +} + +export function fallback(input: Inquiry): Score { + const text = [input.role, input.company, input.message].filter(Boolean).join("\n").toLowerCase() + const procurement = /procurement|security|vendor|legal|approval|questionnaire|compliance/.test(text) + const words = input.message.trim().split(/\s+/).filter(Boolean).length + return { + company: field(input.company), + size: null, + first: input.name.split(/\s+/)[0] ?? null, + title: field(input.role), + seats: null, + procurement, + effort: procurement ? "high" : words < 18 ? "low" : "medium", + summary: null, + } +} + +async function grade(input: Inquiry): Promise { + const zen = createOpenAI({ + apiKey: "public", + baseURL: "https://opencode.ai/zen/v1", + }) + + return generateObject({ + model: zen.responses("gpt-5"), + schema: shape, + system, + prompt: JSON.stringify( + { + name: input.name, + role: input.role, + company: field(input.company), + email: input.email, + phone: field(input.phone), + message: input.message, + }, + null, + 2, + ), + }) + .then((result) => result.object) + .catch((err) => { + console.error("Failed to grade enterprise inquiry:", err) + return fallback(input) + }) +} + +export function kind(score: Score): Kind | null { + if (score.procurement) return "procurement" + if (score.effort === "low") return "generic" + return null +} + +function refs(kind: Kind) { + const text = links.map( + (item) => `${item.label}: ${item.url}${kind === "procurement" && item.label === "GitHub" ? " (MIT licensed)" : ""}`, + ) + const markup = links + .map( + (item) => + `
  • ${safe(item.label)}${kind === "procurement" && item.label === "GitHub" ? " (MIT licensed)" : ""}
  • `, + ) + .join("") + return { text, markup } +} + +export function reply(kind: Kind, name: string | null): Mail { + const who = name ?? "there" + const list = refs(kind) + + if (kind === "generic") { + return { + subject: "Thanks for reaching out to OpenCode", + text: [ + `Hi ${who},`, + "", + "Thanks for reaching out, we're happy to hear from you! We've received your message and are working through it. We're a small team doing our best to get back to everyone, so thank you for bearing with us.", + "", + "To help while you wait, here are some great places to start:", + ...list.text, + "", + "Hope you find what you need in there! Don't hesitate to reply if you have something more specific in mind.", + "", + "Best,", + sign, + ].join("\n"), + html: [ + `

    Hi ${safe(who)},

    `, + "

    Thanks for reaching out, we're happy to hear from you! We've received your message and are working through it. We're a small team doing our best to get back to everyone, so thank you for bearing with us.

    ", + "

    To help while you wait, here are some great places to start:

    ", + `
      ${list.markup}
    `, + "

    Hope you find what you need in there! Don't hesitate to reply if you have something more specific in mind.

    ", + `

    Best,
    ${safe(sign)}

    `, + ].join(""), + } + } + + return { + subject: "OpenCode security and procurement notes", + text: [ + `Hi ${who},`, + "", + "Thanks for reaching out! We're a small team working through messages as fast as we can, so thanks for bearing with us.", + "", + "A few notes that may help while this moves through security or procurement:", + "- OpenCode is open source and MIT licensed.", + "- Our managed offering is SOC 1 compliant.", + "- Our managed offering is currently in the observation period for SOC 2.", + "", + "If anything is held up on the procurement or legal side, just reply and I'll get you whatever you need to keep things moving.", + "", + "To help while you wait, here are some great places to start:", + ...list.text, + "", + "Best,", + sign, + ].join("\n"), + html: [ + `

    Hi ${safe(who)},

    `, + "

    Thanks for reaching out! We're a small team working through messages as fast as we can, so thanks for bearing with us.

    ", + "

    A few notes that may help while this moves through security or procurement:

    ", + "
    • OpenCode is open source and MIT licensed.
    • Our managed offering is SOC 1 compliant.
    • Our managed offering is currently in the observation period for SOC 2.
    ", + "

    If anything is held up on the procurement or legal side, just reply and I'll get you whatever you need to keep things moving.

    ", + "

    To help while you wait, here are some great places to start:

    ", + `
      ${list.markup}
    `, + `

    Best,
    ${safe(sign)}

    `, + ].join(""), + } +} + +function rows(input: Inquiry, score: Score, kind: Kind | null) { + return [ + { label: "Name", value: input.name }, + { label: "Email", value: input.email }, + { label: "Phone", value: field(input.phone) ?? "Unknown" }, + { label: "Auto Reply", value: kind ?? "manual" }, + { label: "Company", value: score.company ?? "Unknown" }, + { label: "Company Size", value: score.size ?? "Unknown" }, + { label: "First Name", value: score.first ?? "Unknown" }, + { label: "Title", value: score.title ?? "Unknown" }, + { label: "Seats", value: score.seats ? String(score.seats) : "Unknown" }, + { label: "Procurement", value: score.procurement ? "Yes" : "No" }, + { label: "Effort", value: score.effort }, + { label: "Summary", value: score.summary ?? "None" }, + ] +} + +function report(input: Inquiry, score: Score, kind: Kind | null): Mail { + const list = rows(input, score, kind) + return { + subject: `Enterprise Inquiry from ${input.name}${kind ? ` (${kind})` : ""}`, + text: [ + "New enterprise inquiry", + "", + ...list.map((item) => `${item.label}: ${item.value}`), + "", + "Message:", + input.message, + ].join("\n"), + html: [ + "

    New enterprise inquiry

    ", + ...list.map((item) => `

    ${safe(item.label)}: ${html(item.value)}

    `), + `

    Message:
    ${html(input.message)}

    `, + ].join(""), + } +} + +function note(input: Inquiry, score: Score, kind: Kind | null) { + return [input.message, "", "---", ...rows(input, score, kind).map((item) => `${item.label}: ${item.value}`)].join( + "\n", + ) +} + +export async function deliver(input: Inquiry) { + const score = await grade(input) + const next = kind(score) + const msg = report(input, score, next) + const auto = next ? reply(next, score.first) : null + const jobs = [ + { + name: "salesforce", + job: createLead({ + name: input.name, + role: score.title ?? input.role, + company: score.company ?? field(input.company) ?? undefined, + email: input.email, + phone: field(input.phone) ?? undefined, + message: note(input, score, next), + }), + }, + { + name: "internal", + job: AWS.sendEmail({ + from, + to: "contact@anoma.ly", + subject: msg.subject, + body: msg.text, + html: msg.html, + replyTo: input.email, + }), + }, + ...(auto + ? [ + { + name: "reply", + job: AWS.sendEmail({ + from, + to: input.email, + subject: auto.subject, + body: auto.text, + html: auto.html, + }), + }, + ] + : []), + ] + + const out = await Promise.allSettled(jobs.map((item) => item.job)) + out.forEach((item, index) => { + const name = jobs[index]!.name + if (item.status === "rejected") { + console.error(`Enterprise ${name} failed:`, item.reason) + return + } + if (name === "salesforce" && !item.value) { + console.error("Enterprise salesforce lead failed") + } + }) +} diff --git a/packages/console/app/src/routes/api/enterprise.ts b/packages/console/app/src/routes/api/enterprise.ts index 1bc4d0eb29..0176c73d29 100644 --- a/packages/console/app/src/routes/api/enterprise.ts +++ b/packages/console/app/src/routes/api/enterprise.ts @@ -1,23 +1,13 @@ import type { APIEvent } from "@solidjs/start/server" -import { AWS } from "@opencode-ai/console-core/aws.js" +import { waitUntil } from "@opencode-ai/console-resource" import { i18n } from "~/i18n" import { localeFromRequest } from "~/lib/language" -import { createLead } from "~/lib/salesforce" - -interface EnterpriseFormData { - name: string - role: string - company?: string - email: string - phone?: string - alias?: string - message: string -} +import { deliver, type Inquiry } from "~/lib/enterprise" export async function POST(event: APIEvent) { const dict = i18n(localeFromRequest(event.request)) try { - const body = (await event.request.json()) as EnterpriseFormData + const body = (await event.request.json()) as Inquiry const trap = typeof body.alias === "string" ? body.alias.trim() : "" if (trap) { @@ -33,45 +23,14 @@ export async function POST(event: APIEvent) { return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 }) } - const emailContent = ` -${body.message}

    ---
    -${body.name}
    -${body.role}
    -${body.company ? `${body.company}
    ` : ""}${body.email}
    -${body.phone ? `${body.phone}
    ` : ""}`.trim() - - const [lead, mail] = await Promise.all([ - createLead({ - name: body.name, - role: body.role, - company: body.company, - email: body.email, - phone: body.phone, - message: body.message, - }), - AWS.sendEmail({ - to: "contact@anoma.ly", - subject: `Enterprise Inquiry from ${body.name}`, - body: emailContent, - replyTo: body.email, - }).then( - () => true, - (err) => { - console.error("Failed to send enterprise email:", err) - return false - }, - ), - ]) - - if (!lead && !mail) { - console.error("Enterprise inquiry delivery failed", { email: body.email }) - return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 }) - } + const job = deliver(body).catch((error) => { + console.error("Error processing enterprise form:", error) + }) + void waitUntil(job) return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 }) } catch (error) { - console.error("Error processing enterprise form:", error) + console.error("Error reading enterprise form:", error) return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 }) } } diff --git a/packages/console/app/test/enterprise.test.ts b/packages/console/app/test/enterprise.test.ts new file mode 100644 index 0000000000..fb0342cb3e --- /dev/null +++ b/packages/console/app/test/enterprise.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { fallback, kind, reply, type Score } from "../src/lib/enterprise" + +describe("enterprise lead routing", () => { + test("routes procurement blockers to procurement reply", () => { + const score = fallback({ + name: "Jane Doe", + role: "CTO", + company: "Acme", + email: "jane@acme.com", + message: "We're stuck in procurement, security review, and vendor approval through Coupa.", + }) + + expect(score.procurement).toBe(true) + expect(kind(score)).toBe("procurement") + }) + + test("routes vague inquiries to the generic reply", () => { + const score = fallback({ + name: "Jane Doe", + role: "Engineer", + email: "jane@example.com", + message: "Can you tell me more about enterprise pricing?", + }) + + expect(score.effort).toBe("low") + expect(kind(score)).toBe("generic") + }) + + test("keeps high intent leads for manual follow-up", () => { + const score: Score = { + company: "Acme", + size: "1001+", + first: "Jane", + title: "CTO", + seats: 500, + procurement: false, + effort: "high", + summary: "Large rollout with clear buying intent.", + } + + expect(kind(score)).toBeNull() + }) + + test("renders the procurement reply with security notes", () => { + const mail = reply("procurement", "Jane") + + expect(mail.subject).toContain("security") + expect(mail.text).toContain("SOC 1 compliant") + expect(mail.text).toContain("MIT licensed") + expect(mail.html).toContain("Stefan") + }) +}) diff --git a/packages/console/core/src/aws.ts b/packages/console/core/src/aws.ts index a4c1510862..8997628b9b 100644 --- a/packages/console/core/src/aws.ts +++ b/packages/console/core/src/aws.ts @@ -19,12 +19,17 @@ export namespace AWS { export const sendEmail = fn( z.object({ + from: z.string().optional(), to: z.string(), subject: z.string(), body: z.string(), + text: z.string().optional(), + html: z.string().optional(), replyTo: z.string().optional(), }), async (input) => { + const text = input.text ?? input.body + const html = input.html ?? input.body const res = await createClient().fetch("https://email.us-east-1.amazonaws.com/v2/email/outbound-emails", { method: "POST", headers: { @@ -32,7 +37,7 @@ export namespace AWS { "Content-Type": "application/json", }, body: JSON.stringify({ - FromEmailAddress: `OpenCode Zen `, + FromEmailAddress: input.from ?? "OpenCode Zen ", Destination: { ToAddresses: [input.to], }, @@ -46,11 +51,11 @@ export namespace AWS { Body: { Text: { Charset: "UTF-8", - Data: input.body, + Data: text, }, Html: { Charset: "UTF-8", - Data: input.body, + Data: html, }, }, },