diff --git a/front-to-admin-chat/front-to-admin-chat.sh b/front-to-admin-chat/front-to-admin-chat.sh new file mode 100755 index 0000000..86a9183 --- /dev/null +++ b/front-to-admin-chat/front-to-admin-chat.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title Front โ†’ Admin Chat +# @raycast.mode fullOutput + +# Optional parameters: +# @raycast.icon ๐Ÿ’ฌ +# @raycast.argument1 { "type": "text", "placeholder": "cnv_xxx or Front URL (empty = active tab)", "optional": true } +# @raycast.argument2 { "type": "text", "placeholder": "Custom ask (optional)", "optional": true } +# @raycast.packageName CX + +# Documentation: +# @raycast.description From a Front conversation (active tab or cnv_id), build a question with customer email, UserID, ProjectIDs and the message body, then open the Bolt admin chat with it pre-filled. +# @raycast.author Jorrit Harmamny + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +ARGS=() +[[ -n "${1:-}" ]] && ARGS+=("$1") +if [[ -n "${2:-}" ]]; then + ARGS+=(--ask "$2") +fi + +node "$SCRIPT_DIR/index.js" "${ARGS[@]}" diff --git a/front-to-admin-chat/index.js b/front-to-admin-chat/index.js new file mode 100644 index 0000000..02f75e7 --- /dev/null +++ b/front-to-admin-chat/index.js @@ -0,0 +1,453 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Front โ†’ Admin Chat + * + * Takes a Front conversation (active tab or cnv_id arg), pulls customer + * context + message body, resolves UserID via stackblitz.com/admin, extracts + * ProjectIDs from the message, then opens the bolt admin chat with a + * pre-filled question via the `?q=` URL param. + * + * Usage: + * node index.js # use active Front tab + * node index.js cnv_17xisntd # explicit cnv_id + * node index.js https://app.frontapp.com/open/cnv_17xisntd + * node index.js --ask "Why did their build fail?" # override question + * node index.js --print # print URL, don't open + * + * Env (loaded from ../cx-briefing/.env): + * FRONT_API_KEY (required) + * BOLT_ADMIN_CHAT_URL (optional โ€” default https://hackday-admin-chat.bolt-cnr.pages.dev) + */ + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const { execSync, execFileSync } = require('child_process'); + +// โ”€โ”€ Load .env โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +(function loadEnv() { + const envPath = path.join(__dirname, '..', 'cx-briefing', '.env'); + if (!fs.existsSync(envPath)) return; + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + const eq = t.indexOf('='); + if (eq < 0) continue; + const key = t.slice(0, eq).trim(); + const val = t.slice(eq + 1).trim().replace(/^["']|["']$/g, ''); + if (key && !process.env[key]) process.env[key] = val; + } +})(); + +const argv = process.argv.slice(2); +const POSITIONAL = argv.filter(a => !a.startsWith('--')); +const PRINT_ONLY = argv.includes('--print'); + +function flagValue(name) { + const i = argv.indexOf(name); + if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--')) return argv[i + 1]; + const inline = argv.find(a => a.startsWith(`${name}=`)); + return inline ? inline.split('=').slice(1).join('=') : null; +} + +const ADMIN_CHAT_BASE = (process.env.BOLT_ADMIN_CHAT_URL || 'https://hackday-admin-chat.bolt-cnr.pages.dev').replace(/\/$/, ''); +const CUSTOM_ASK = flagValue('--ask'); +const BODY_CAP = 3000; + +// โ”€โ”€ HTTP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function apiRequest(urlStr, { method = 'GET', headers = {} } = {}) { + return new Promise((resolve, reject) => { + const url = new URL(urlStr); + const req = https.request({ + hostname: url.hostname, + path: url.pathname + url.search, + method, + headers: { 'Content-Type': 'application/json', 'User-Agent': 'front-to-admin-chat/1.0', ...headers }, + }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); } + catch { resolve({ status: res.statusCode, body: raw }); } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +// โ”€โ”€ AppleScript helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const BROWSERS = ['Google Chrome', 'Brave Browser', 'Microsoft Edge', 'Arc', 'Chromium', 'Dia']; + +function pickBrowser() { + for (const app of BROWSERS) { + try { + const out = execFileSync('/usr/bin/osascript', ['-e', `application "${app}" is running`], { encoding: 'utf8' }); + if (out.trim() === 'true') return app; + } catch {} + } + return 'Google Chrome'; +} + +function activeTabUrl(app) { + const script = ` +on run argv + set appName to item 1 of argv + try + using terms from application "Google Chrome" + tell application appName + if (count of windows) is 0 then return "" + return URL of active tab of front window + end tell + end using terms from + on error + return "" + end try +end run`; + try { + return execFileSync('/usr/bin/osascript', ['-', app], { input: script, encoding: 'utf8' }).trim(); + } catch { return ''; } +} + +function openTabAndRunJs(app, url, js) { + const script = ` +on run argv + set targetURL to item 1 of argv + set jsExpr to item 2 of argv + try + using terms from application "Google Chrome" + tell application "${app}" + activate + if (count of windows) is 0 then make new window + set theWindow to front window + set theTab to make new tab at the end of tabs of theWindow with properties {URL:targetURL} + delay 0.5 + set maxTries to 60 + repeat with i from 1 to maxTries + try + set state to execute theTab javascript "document.readyState" + if state is "complete" then exit repeat + end try + delay 0.25 + end repeat + delay 0.4 + set result_ to "" + try + set result_ to execute theTab javascript jsExpr + if result_ is missing value then set result_ to "" + end try + return result_ + end tell + end using terms from + on error errMsg + return "ERROR: " & errMsg + end try +end run`; + try { + return execFileSync('/usr/bin/osascript', ['-', url, js], { input: script, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }).trimEnd(); + } catch (e) { + return `ERROR: ${e.message}`; + } +} + +function setTabUrlAndReadJs(app, url, js) { + const script = ` +on run argv + set targetURL to item 1 of argv + set jsExpr to item 2 of argv + try + using terms from application "Google Chrome" + tell application "${app}" + if (count of windows) is 0 then make new window + set theTab to active tab of front window + set URL of theTab to targetURL + delay 0.5 + set maxTries to 60 + repeat with i from 1 to maxTries + try + set state to execute theTab javascript "document.readyState" + if state is "complete" then exit repeat + end try + delay 0.25 + end repeat + delay 0.6 + set result_ to "" + try + set result_ to execute theTab javascript jsExpr + if result_ is missing value then set result_ to "" + end try + return result_ + end tell + end using terms from + on error errMsg + return "ERROR: " & errMsg + end try +end run`; + try { + return execFileSync('/usr/bin/osascript', ['-', url, js], { input: script, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }).trimEnd(); + } catch (e) { + return `ERROR: ${e.message}`; + } +} + +// โ”€โ”€ cnv_id resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function extractCnvId(input) { + if (!input) return null; + const m = input.match(/cnv_[a-z0-9]+/i); + return m ? m[0] : (/^cnv_/i.test(input) ? input : null); +} + +function resolveCnvId() { + // 1. positional arg + for (const arg of POSITIONAL) { + const id = extractCnvId(arg); + if (id) return { cnv_id: id, source: 'arg' }; + } + // 2. active Front tab + const app = pickBrowser(); + const url = activeTabUrl(app); + const id = extractCnvId(url); + if (id) return { cnv_id: id, source: 'active-tab', url }; + return null; +} + +// โ”€โ”€ Front fetch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function fetchFrontConversation(cnv_id) { + if (!process.env.FRONT_API_KEY) throw new Error('FRONT_API_KEY missing in cx-briefing/.env'); + const h = { Authorization: `Bearer ${process.env.FRONT_API_KEY}` }; + + const [convRes, msgRes] = await Promise.all([ + apiRequest(`https://api2.frontapp.com/conversations/${cnv_id}`, { headers: h }), + apiRequest(`https://api2.frontapp.com/conversations/${cnv_id}/messages?limit=10`, { headers: h }), + ]); + if (convRes.status !== 200) throw new Error(`Front conversation HTTP ${convRes.status}: ${JSON.stringify(convRes.body).slice(0, 200)}`); + if (msgRes.status !== 200) throw new Error(`Front messages HTTP ${msgRes.status}`); + + const conv = convRes.body; + const messages = msgRes.body._results || []; + // Earliest inbound message (the customer's original ask) + const inbound = messages + .filter(m => m.is_inbound) + .sort((a, b) => (a.created_at || 0) - (b.created_at || 0))[0]; + + let bodyText = ''; + if (inbound) { + bodyText = inbound.text || ''; + if (!bodyText && inbound.body) { + bodyText = inbound.body.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim(); + } + // Strip Front template placeholders ({text}, {{customer.name}}, etc.) + bodyText = bodyText + .replace(/\{\{[^}]*\}\}/g, '') + .replace(/\{[\w.]+\}/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } + + // Contact email โ€” try recipients first, then handles, then conversation contact + let email = null; + if (inbound?.recipients) { + const sender = inbound.recipients.find(r => r.role === 'from'); + if (sender?.handle) email = sender.handle; + } + if (!email && conv.recipient?.handle) email = conv.recipient.handle; + + return { + cnv_id, + subject: conv.subject || '(no subject)', + status: conv.status, + email, + bodyText, + createdAt: conv.created_at, + openUrl: `https://app.frontapp.com/open/${cnv_id}`, + }; +} + +// โ”€โ”€ Stackblitz admin lookup (UserID + recent projects + orgs by email) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +async function lookupAdmin(email) { + const app = pickBrowser(); + const searchUrl = `https://stackblitz.com/admin/users?q%5Bby_email_address%5D=${encodeURIComponent(email)}&commit=Filter&order=id_desc`; + + // Stage 1: open search, get profile link. The search-results table sometimes + // renders after readyState=complete (it's part of a follow-up render). Retry + // the find a few times with a short gap before giving up. + const findProfileJs = `(function(){ + try { + const scope = document.querySelector('#index_table_users tbody') || document.querySelector('#index_table_users') || document.querySelector('table.index_table') || document; + const link = Array.from(scope.querySelectorAll('a[href]')).find(function(a) { + return /\\/admin\\/users\\/(?!new(?:[^a-z]|$))(?!new_)[^?#\\/]+$/.test(a.href); + }); + return link ? link.href : ''; + } catch(e) { return ''; } + })()`; + // After the search URL is open, re-run the JS on the same active tab via + // `tell ... execute javascript` without re-navigating. + const rerunFindOnActiveTab = ` +on run argv + set jsExpr to item 1 of argv + try + using terms from application "Google Chrome" + tell application "${app}" + if (count of windows) is 0 then return "" + set theTab to active tab of front window + return execute theTab javascript jsExpr + end tell + end using terms from + on error errMsg + return "ERROR: " & errMsg + end try +end run`; + + let profileUrl = openTabAndRunJs(app, searchUrl, findProfileJs); + for (let attempt = 1; attempt <= 4 && (!profileUrl || profileUrl.startsWith('ERROR:')); attempt++) { + await sleep(800); + try { + profileUrl = execFileSync('/usr/bin/osascript', ['-', findProfileJs], { input: rerunFindOnActiveTab, encoding: 'utf8' }).trim(); + } catch (e) { + profileUrl = `ERROR: ${e.message}`; + } + } + if (!profileUrl || profileUrl.startsWith('ERROR:')) return { error: profileUrl || 'no profile link after retries', searchUrl }; + + // Stage 2: read full profile bodyText (raised cap so the projects table fits) + const readBodyJs = `(function(){ try { return JSON.stringify({ url: location.href, body: (document.body.innerText || '').slice(0, 12000) }); } catch(e) { return JSON.stringify({ error: String(e.message || e) }); } })()`; + const raw = setTabUrlAndReadJs(app, profileUrl, readBodyJs); + if (raw.startsWith('ERROR:')) return { error: raw.slice(6), searchUrl, profileUrl }; + let parsed; try { parsed = JSON.parse(raw); } catch { return { error: 'parse fail', searchUrl, profileUrl }; } + const body = parsed.body || ''; + const lines = body.split('\n').map(l => l.trim()); + + // UserID + let userId = null; + const userIdIdx = lines.findIndex(l => /^USER ID$/i.test(l)); + if (userIdIdx >= 0) { + for (let i = userIdIdx + 1; i < Math.min(userIdIdx + 5, lines.length); i++) { + const m = lines[i].match(/#?(\d{4,})/); + if (m) { userId = m[1]; break; } + } + } + if (!userId) { + const fb = body.match(/\b(\d{6,9})\b/); + if (fb) userId = fb[1]; + } + + // Recent project slugs โ€” table rows after "Last 10 projects" / "All projects" + // start with: \t\t... where slug matches sb1-/github-/webcontainer- + const projectSlugs = []; + for (const m of body.matchAll(/^\s*\d{6,}\s*[\t ]\s*((?:sb1|github|webcontainer)-[a-z0-9-]+)\b/gim)) { + if (!projectSlugs.includes(m[1])) projectSlugs.push(m[1]); + } + + // Orgs โ€” after "Organizations" header, before "Token stats" footer + const orgs = []; + const orgIdx = lines.findIndex(l => /^Organizations$/.test(l)); + if (orgIdx >= 0) { + for (let i = orgIdx + 2; i < Math.min(orgIdx + 30, lines.length); i++) { + const l = lines[i]; + if (!l) continue; + if (/^(Token stats|Reset tokens|Last 10|All projects|Per-User|OAuth|Sessions)/i.test(l)) break; + const orgMatch = l.match(/^([a-z0-9][a-z0-9-]*)(?:\s|\t)/i); + if (orgMatch) orgs.push(orgMatch[1]); + } + } + + if (!userId) return { error: 'USER ID not found on profile page', profileUrl }; + return { userId, projectSlugs, orgs, profileUrl }; +} + +// โ”€โ”€ ProjectID extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function extractProjectIds(text) { + if (!text) return []; + const out = new Set(); + // sb1-xxxxxx (StackBlitz starter slugs) + for (const m of text.matchAll(/\bsb1-[a-z0-9]+\b/gi)) out.add(m[0]); + // github-xxxxxx (imported from GitHub) + for (const m of text.matchAll(/\bgithub-[a-z0-9]+\b/gi)) out.add(m[0]); + // webcontainer-xxxxxx + for (const m of text.matchAll(/\bwebcontainer-[a-z0-9]+\b/gi)) out.add(m[0]); + // bolt.new project URLs: bolt.new/~/sb1-xxx, bolt.new/~/github-xxx + for (const m of text.matchAll(/bolt\.new\/~\/([a-z0-9-]+)/gi)) out.add(m[1]); + return [...out]; +} + +// โ”€โ”€ Build question โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function buildQuestion({ front, userId, projectIdsFromBody, projectSlugsFromAdmin, orgs }) { + const finalAsk = CUSTOM_ASK || 'What were their recent chats and any errors in the last 24h?'; + const body = (front.bodyText || '').slice(0, BODY_CAP); + const truncated = (front.bodyText || '').length > BODY_CAP; + const bodyBlock = body ? `${body}${truncated ? '\nโ€ฆ[truncated]' : ''}` : '(no message body)'; + + // Prefer body-mentioned projects (customer flagged them); fall back to recent from admin + const projects = projectIdsFromBody.length + ? { source: 'mentioned in message', ids: projectIdsFromBody } + : projectSlugsFromAdmin?.length + ? { source: 'recent from admin', ids: projectSlugsFromAdmin.slice(0, 8) } + : null; + + const parts = [ + `Customer ${front.email || '(unknown email)'}${userId ? ` (user id: ${userId})` : ''} sent this in Front (subject: "${front.subject}"):`, + '', + bodyBlock, + '', + ]; + if (projects) parts.push(`Projects (${projects.source}): ${projects.ids.join(', ')}`); + else parts.push('Projects: none mentioned and none recent in admin'); + if (orgs?.length) parts.push(`Orgs: ${orgs.join(', ')}`); + parts.push('', finalAsk); + return parts.join('\n'); +} + +// โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +(async () => { + const resolved = resolveCnvId(); + if (!resolved) { + console.error('No cnv_id found. Pass one as an argument or run with an active Front conversation tab.'); + process.exit(1); + } + console.log(`โ†’ Front conversation: ${resolved.cnv_id} (from ${resolved.source})`); + + let front; + try { front = await fetchFrontConversation(resolved.cnv_id); } + catch (e) { console.error(`โœ— Front fetch failed: ${e.message}`); process.exit(1); } + + if (!front.email) { + console.warn('โš  No customer email on the conversation โ€” UserID lookup will be skipped.'); + } + + let userId = null; + let projectSlugsFromAdmin = []; + let orgs = []; + if (front.email) { + console.log(`โ†’ Looking up admin profile for ${front.email}โ€ฆ`); + const r = await lookupAdmin(front.email); + if (r.userId) { + userId = r.userId; + projectSlugsFromAdmin = r.projectSlugs || []; + orgs = r.orgs || []; + console.log(` โœ“ UserID: ${userId}${projectSlugsFromAdmin.length ? ` ยท ${projectSlugsFromAdmin.length} recent project(s)` : ''}${orgs.length ? ` ยท orgs: ${orgs.join(', ')}` : ''}`); + } else { + console.warn(` โš  Lookup failed: ${r.error}`); + } + } + + const projectIdsFromBody = extractProjectIds(front.bodyText); + if (projectIdsFromBody.length) console.log(`โ†’ ProjectIDs in message body: ${projectIdsFromBody.join(', ')}`); + + const question = buildQuestion({ front, userId, projectIdsFromBody, projectSlugsFromAdmin, orgs }); + const url = `${ADMIN_CHAT_BASE}/admin/chat?q=${encodeURIComponent(question)}`; + + if (PRINT_ONLY) { + console.log('\n--- question ---'); + console.log(question); + console.log('\n--- url ---'); + console.log(url); + return; + } + console.log(`โ†’ Opening admin chat (${url.length} chars in URL)โ€ฆ`); + execSync(`open "${url.replace(/"/g, '\\"')}"`); +})().catch(e => { console.error('fatal:', e); process.exit(1); });