// The HubSpot companion app (docs/integrations.md § companion apps). Once // you've connected HubSpot in Integrations (OAuth or a private-app token), // this app installs: recent contacts and deals as a small dashboard in its // own thread. // // Same rail as the GitHub and Stripe apps: NO HubSpot sidecar. Every fetch // goes through ctx.host.connected.requestOp (a one-shot read grant sealed to // the instance's existing bot); results are bounded by the hubspot manifest's // `pick` projections, fold into state as typed events, and sync across // devices via snapshots. TAKEOVER app (no chatMessage): CRM data never echoes // into chat bubbles or notifications. import { defineApp } from "../apps.js"; import { LOGO_HUBSPOT } from "../logos.js"; // Results arrive pick-projected with flattened dot-path keys // ("properties.email") — normalise once at fold time, drop the malformed. const normContacts = (list) => (Array.isArray(list) ? list : []) .filter((c) => c && typeof c.id === "string") .map((c) => ({ id: c.id, email: typeof c["properties.email"] === "string" ? c["properties.email"] : "", first: typeof c["properties.firstname"] === "string" ? c["properties.firstname"] : "", last: typeof c["properties.lastname"] === "string" ? c["properties.lastname"] : "", created: typeof c.createdAt === "string" ? c.createdAt : "", })); const normDeals = (list) => (Array.isArray(list) ? list : []) .filter((d) => d && typeof d.id === "string") .map((d) => ({ id: d.id, name: typeof d["properties.dealname"] === "string" ? d["properties.dealname"] : "", amount: typeof d["properties.amount"] === "string" ? d["properties.amount"] : "", stage: typeof d["properties.dealstage"] === "string" ? d["properties.dealstage"] : "", close: typeof d["properties.closedate"] === "string" ? d["properties.closedate"] : "", created: typeof d.createdAt === "string" ? d.createdAt : "", })); // ISO stamp → short local date; "" when unparsable. function day(iso) { const t = Date.parse(iso || ""); if (!t) return ""; try { return new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short" }); } catch { return ""; } } // HubSpot deal stages are internal slugs ("appointmentscheduled") — make them // readable without pretending to know the pipeline's real labels. const stageLabel = (s) => (s || "").replace(/[_-]+/g, " ").trim() || "—"; let busy = false; let autoLoading = false; export const app = defineApp({ name: "hubspot", title: "HubSpot", blurb: "Contacts & deals", glyph: "🧲", mark: "hubspot", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) logo: LOGO_HUBSPOT, places: ["you"], // Only listed in New chat once HubSpot is linked (shell.js gates on this). requiresService: "hubspot", initialState: () => ({ contacts: [], deals: [], fetchedAt: 0 }), events: { "hubspot.contacts": (s, e) => (Array.isArray(e.contacts) ? { ...s, contacts: normContacts(e.contacts), fetchedAt: e.ts || s.fetchedAt } : s), "hubspot.deals": (s, e) => (Array.isArray(e.deals) ? { ...s, deals: normDeals(e.deals), fetchedAt: e.ts || s.fetchedAt } : s), }, styles: ` .hs-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } .hs-title { margin: 0; font-size: 18px; } .hs-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; } .hs-btn { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); } .hs-btn:disabled { opacity: .6; cursor: default; } .hs-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); } .hs-scroll { overflow-x: auto; margin: 0 -4px; } .hs-table { width: 100%; border-collapse: collapse; font-size: 14px; } .hs-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; } .hs-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } .hs-name { font-weight: 500; overflow-wrap: anywhere; } .hs-muted { color: var(--muted); overflow-wrap: anywhere; } .hs-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 500; } .hs-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } .hs-empty { color: var(--muted); padding: 8px 0; } .hs-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } `, render(state, el, ctx) { const host = ctx.host && ctx.host.connected; state = state || { contacts: [], deals: [], fetchedAt: 0 }; const head = document.createElement("div"); head.className = "hs-head"; el.appendChild(head); const title = document.createElement("h2"); title.className = "hs-title"; title.textContent = "HubSpot"; const sub = document.createElement("span"); sub.className = "hs-sub"; const note = document.createElement("div"); note.className = "hs-note"; if (!host || !host.requestOp) { head.appendChild(title); const empty = document.createElement("div"); empty.className = "hs-empty"; empty.textContent = "HubSpot needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet)."; el.append(empty); return; } // One Refresh pulls both reads; each folds as its own event so a failing // one never blanks the other. Whole body under try/finally so a throw can // never wedge the module-level busy flag. const refresh = async () => { if (busy) return; busy = true; const errors = []; try { note.textContent = ""; paint(); const pulls = [ ["contacts.list", (d) => ctx.emit({ type: "hubspot.contacts", contacts: d })], ["deals.list", (d) => ctx.emit({ type: "hubspot.deals", deals: d })], ]; await Promise.all( pulls.map(async ([op, emit]) => { let res; try { res = await host.requestOp("hubspot", op, {}); } catch (e) { res = { ok: false, error: (e && e.message) || String(e) }; } if (res && res.ok && typeof res.data === "string") { try { emit(JSON.parse(res.data)); return; } catch {} } errors.push(`${op}: ${(res && res.error) || "no response"}`); }), ); } finally { busy = false; autoLoading = false; } if (errors.length) note.textContent = `Couldn't refresh everything — ${errors[0]}`; paint(); }; const refreshBtn = document.createElement("button"); refreshBtn.type = "button"; refreshBtn.className = "hs-btn"; refreshBtn.textContent = "↻ Refresh"; refreshBtn.onclick = refresh; head.append(title, sub, refreshBtn); const section = (heading, rows, cols, rowCells) => { const h = document.createElement("div"); h.className = "hs-h"; h.textContent = heading; el.appendChild(h); if (!rows.length) { const empty = document.createElement("div"); empty.className = "hs-empty"; empty.textContent = state.fetchedAt ? "Nothing here." : "Not loaded yet — hit Refresh."; el.appendChild(empty); return; } const scroll = document.createElement("div"); scroll.className = "hs-scroll"; const table = document.createElement("table"); table.className = "hs-table"; const thead = document.createElement("thead"); const hr = document.createElement("tr"); for (const c of cols) { const th = document.createElement("th"); th.textContent = c; hr.appendChild(th); } thead.appendChild(hr); table.appendChild(thead); const tbody = document.createElement("tbody"); for (const r of rows) { const tr = document.createElement("tr"); for (const cell of rowCells(r)) tr.appendChild(cell); tbody.appendChild(tr); } table.appendChild(tbody); scroll.appendChild(table); el.appendChild(scroll); }; const td = (text, cls) => { const c = document.createElement("td"); if (cls) c.className = cls; c.textContent = text; return c; }; section("Recent contacts", state.contacts, ["Name", "Email", "Added"], (c) => [ td([c.first, c.last].filter(Boolean).join(" ") || "—", "hs-name"), td(c.email || "—", "hs-muted"), td(day(c.created), "hs-when"), ]); section("Recent deals", state.deals, ["Deal", "Amount", "Stage", "Closes"], (d) => [ td(d.name || "—", "hs-name"), td(d.amount ? d.amount : "—", "hs-amount"), td(stageLabel(d.stage), "hs-muted"), td(day(d.close), "hs-when"), ]); el.appendChild(note); const paint = () => { sub.textContent = busy ? "loading…" : state.fetchedAt ? `updated ${new Date(state.fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""; refreshBtn.disabled = busy; }; paint(); // First open pulls once, automatically — after the surface is fully built. if (!state.fetchedAt && !busy && !autoLoading) { autoLoading = true; refresh(); } }, });