eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// The HubSpot companion app (docs/integrations.md § companion apps). Once
2// you've connected HubSpot in Integrations (OAuth or a private-app token),
3// this app installs: recent contacts and deals as a small dashboard in its
4// own thread.
5//
6// Same rail as the GitHub and Stripe apps: NO HubSpot sidecar. Every fetch
7// goes through ctx.host.connected.requestOp (a one-shot read grant sealed to
8// the instance's existing bot); results are bounded by the hubspot manifest's
9// `pick` projections, fold into state as typed events, and sync across
10// devices via snapshots. TAKEOVER app (no chatMessage): CRM data never echoes
11// into chat bubbles or notifications.
12import { defineApp } from "../apps.js";
13import { LOGO_HUBSPOT } from "../logos.js";
14
15// Results arrive pick-projected with flattened dot-path keys
16// ("properties.email") — normalise once at fold time, drop the malformed.
17const normContacts = (list) =>
18 (Array.isArray(list) ? list : [])
19 .filter((c) => c && typeof c.id === "string")
20 .map((c) => ({
21 id: c.id,
22 email: typeof c["properties.email"] === "string" ? c["properties.email"] : "",
23 first: typeof c["properties.firstname"] === "string" ? c["properties.firstname"] : "",
24 last: typeof c["properties.lastname"] === "string" ? c["properties.lastname"] : "",
25 created: typeof c.createdAt === "string" ? c.createdAt : "",
26 }));
27
28const normDeals = (list) =>
29 (Array.isArray(list) ? list : [])
30 .filter((d) => d && typeof d.id === "string")
31 .map((d) => ({
32 id: d.id,
33 name: typeof d["properties.dealname"] === "string" ? d["properties.dealname"] : "",
34 amount: typeof d["properties.amount"] === "string" ? d["properties.amount"] : "",
35 stage: typeof d["properties.dealstage"] === "string" ? d["properties.dealstage"] : "",
36 close: typeof d["properties.closedate"] === "string" ? d["properties.closedate"] : "",
37 created: typeof d.createdAt === "string" ? d.createdAt : "",
38 }));
39
40// ISO stamp → short local date; "" when unparsable.
41function day(iso) {
42 const t = Date.parse(iso || "");
43 if (!t) return "";
44 try {
45 return new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short" });
46 } catch {
47 return "";
48 }
49}
50
51// HubSpot deal stages are internal slugs ("appointmentscheduled") — make them
52// readable without pretending to know the pipeline's real labels.
53const stageLabel = (s) => (s || "").replace(/[_-]+/g, " ").trim() || "—";
54
55let busy = false;
56let autoLoading = false;
57
58export const app = defineApp({
59 name: "hubspot",
60 title: "HubSpot",
61 blurb: "Contacts & deals",
62 glyph: "🧲",
63 mark: "hubspot", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
64 logo: LOGO_HUBSPOT,
65 places: ["you"],
66 // Only listed in New chat once HubSpot is linked (shell.js gates on this).
67 requiresService: "hubspot",
68 initialState: () => ({ contacts: [], deals: [], fetchedAt: 0 }),
69
70 events: {
71 "hubspot.contacts": (s, e) => (Array.isArray(e.contacts) ? { ...s, contacts: normContacts(e.contacts), fetchedAt: e.ts || s.fetchedAt } : s),
72 "hubspot.deals": (s, e) => (Array.isArray(e.deals) ? { ...s, deals: normDeals(e.deals), fetchedAt: e.ts || s.fetchedAt } : s),
73 },
74
75 styles: `
76.hs-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
77.hs-title { margin: 0; font-size: 18px; }
78.hs-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; }
79.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); }
80.hs-btn:disabled { opacity: .6; cursor: default; }
81.hs-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); }
82.hs-scroll { overflow-x: auto; margin: 0 -4px; }
83.hs-table { width: 100%; border-collapse: collapse; font-size: 14px; }
84.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; }
85.hs-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
86.hs-name { font-weight: 500; overflow-wrap: anywhere; }
87.hs-muted { color: var(--muted); overflow-wrap: anywhere; }
88.hs-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 500; }
89.hs-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
90.hs-empty { color: var(--muted); padding: 8px 0; }
91.hs-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); }
92`,
93
94 render(state, el, ctx) {
95 const host = ctx.host && ctx.host.connected;
96 state = state || { contacts: [], deals: [], fetchedAt: 0 };
97
98 const head = document.createElement("div");
99 head.className = "hs-head";
100 el.appendChild(head);
101
102 const title = document.createElement("h2");
103 title.className = "hs-title";
104 title.textContent = "HubSpot";
105 const sub = document.createElement("span");
106 sub.className = "hs-sub";
107
108 const note = document.createElement("div");
109 note.className = "hs-note";
110
111 if (!host || !host.requestOp) {
112 head.appendChild(title);
113 const empty = document.createElement("div");
114 empty.className = "hs-empty";
115 empty.textContent = "HubSpot needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet).";
116 el.append(empty);
117 return;
118 }
119
120 // One Refresh pulls both reads; each folds as its own event so a failing
121 // one never blanks the other. Whole body under try/finally so a throw can
122 // never wedge the module-level busy flag.
123 const refresh = async () => {
124 if (busy) return;
125 busy = true;
126 const errors = [];
127 try {
128 note.textContent = "";
129 paint();
130 const pulls = [
131 ["contacts.list", (d) => ctx.emit({ type: "hubspot.contacts", contacts: d })],
132 ["deals.list", (d) => ctx.emit({ type: "hubspot.deals", deals: d })],
133 ];
134 await Promise.all(
135 pulls.map(async ([op, emit]) => {
136 let res;
137 try {
138 res = await host.requestOp("hubspot", op, {});
139 } catch (e) {
140 res = { ok: false, error: (e && e.message) || String(e) };
141 }
142 if (res && res.ok && typeof res.data === "string") {
143 try {
144 emit(JSON.parse(res.data));
145 return;
146 } catch {}
147 }
148 errors.push(`${op}: ${(res && res.error) || "no response"}`);
149 }),
150 );
151 } finally {
152 busy = false;
153 autoLoading = false;
154 }
155 if (errors.length) note.textContent = `Couldn't refresh everything — ${errors[0]}`;
156 paint();
157 };
158
159 const refreshBtn = document.createElement("button");
160 refreshBtn.type = "button";
161 refreshBtn.className = "hs-btn";
162 refreshBtn.textContent = "↻ Refresh";
163 refreshBtn.onclick = refresh;
164 head.append(title, sub, refreshBtn);
165
166 const section = (heading, rows, cols, rowCells) => {
167 const h = document.createElement("div");
168 h.className = "hs-h";
169 h.textContent = heading;
170 el.appendChild(h);
171 if (!rows.length) {
172 const empty = document.createElement("div");
173 empty.className = "hs-empty";
174 empty.textContent = state.fetchedAt ? "Nothing here." : "Not loaded yet — hit Refresh.";
175 el.appendChild(empty);
176 return;
177 }
178 const scroll = document.createElement("div");
179 scroll.className = "hs-scroll";
180 const table = document.createElement("table");
181 table.className = "hs-table";
182 const thead = document.createElement("thead");
183 const hr = document.createElement("tr");
184 for (const c of cols) {
185 const th = document.createElement("th");
186 th.textContent = c;
187 hr.appendChild(th);
188 }
189 thead.appendChild(hr);
190 table.appendChild(thead);
191 const tbody = document.createElement("tbody");
192 for (const r of rows) {
193 const tr = document.createElement("tr");
194 for (const cell of rowCells(r)) tr.appendChild(cell);
195 tbody.appendChild(tr);
196 }
197 table.appendChild(tbody);
198 scroll.appendChild(table);
199 el.appendChild(scroll);
200 };
201
202 const td = (text, cls) => {
203 const c = document.createElement("td");
204 if (cls) c.className = cls;
205 c.textContent = text;
206 return c;
207 };
208
209 section("Recent contacts", state.contacts, ["Name", "Email", "Added"], (c) => [
210 td([c.first, c.last].filter(Boolean).join(" ") || "—", "hs-name"),
211 td(c.email || "—", "hs-muted"),
212 td(day(c.created), "hs-when"),
213 ]);
214 section("Recent deals", state.deals, ["Deal", "Amount", "Stage", "Closes"], (d) => [
215 td(d.name || "—", "hs-name"),
216 td(d.amount ? d.amount : "—", "hs-amount"),
217 td(stageLabel(d.stage), "hs-muted"),
218 td(day(d.close), "hs-when"),
219 ]);
220
221 el.appendChild(note);
222
223 const paint = () => {
224 sub.textContent = busy ? "loading…" : state.fetchedAt ? `updated ${new Date(state.fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "";
225 refreshBtn.disabled = busy;
226 };
227 paint();
228
229 // First open pulls once, automatically — after the surface is fully built.
230 if (!state.fetchedAt && !busy && !autoLoading) {
231 autoLoading = true;
232 refresh();
233 }
234 },
235});
236