#!/usr/bin/env node // DocFast Support CLI — FreeScout API client for support ticket management // Env vars (in .credentials/docfast.env): // FREESCOUT_URL — FreeScout instance URL (e.g. https://support.cloonar.com) // FREESCOUT_API_KEY — API key (FreeScout → Manage → API Keys) // FREESCOUT_MAILBOX_ID — Mailbox ID for support@docfast.dev const fs = require('fs'); const path = require('path'); const https = require('https'); const http = require('http'); const credFile = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'docfast.env'); const env = {}; if (fs.existsSync(credFile)) { fs.readFileSync(credFile, 'utf8').split('\n').forEach(l => { const m = l.match(/^([A-Z_]+)=(.+)$/); if (m) env[m[1]] = m[2].trim(); }); } const BASE_URL = env.FREESCOUT_URL; const API_KEY = env.FREESCOUT_API_KEY; const MAILBOX_ID = env.FREESCOUT_MAILBOX_ID; if (!BASE_URL || !API_KEY) { console.error('Missing FREESCOUT_URL or FREESCOUT_API_KEY in credentials.'); process.exit(1); } // --- HTTP helper --- function apiRequest(method, endpoint, body = null) { return new Promise((resolve, reject) => { const url = new URL(`/api${endpoint}`, BASE_URL); const mod = url.protocol === 'https:' ? https : http; const opts = { method, hostname: url.hostname, port: url.port, path: url.pathname + url.search, headers: { 'X-FreeScout-API-Key': API_KEY, 'Content-Type': 'application/json', 'Accept': 'application/json', }, }; const req = mod.request(opts, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => { try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } catch { resolve({ status: res.statusCode, data }); } }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } // --- Commands --- async function cmdTickets(args) { const status = getFlag(args, '--status') || 'active'; // active|pending|closed|spam const page = getFlag(args, '--page') || '1'; let endpoint = `/conversations?page=${page}`; if (MAILBOX_ID) endpoint += `&mailboxId=${MAILBOX_ID}`; if (status) endpoint += `&status=${status}`; const res = await apiRequest('GET', endpoint); if (res.status !== 200) { console.error('Error:', res.status, JSON.stringify(res.data)); process.exit(1); } const conversations = res.data._embedded?.conversations || []; if (conversations.length === 0) { console.log('No tickets found.'); return; } console.log('ID\tSTATUS\tSUBJECT\tCUSTOMER\tCREATED\tTHREADS'); for (const c of conversations) { const customer = c.customer?.email || c.customer?.firstName || 'unknown'; const created = c.createdAt?.split('T')[0] || '-'; console.log(`${c.id}\t${c.status}\t${c.subject}\t${customer}\t${created}\t${c.threads || '-'}`); } if (res.data.page?.totalPages > 1) { console.log(`\nPage ${res.data.page.current}/${res.data.page.totalPages} (${res.data.page.total} total)`); } } async function cmdView(args) { const id = args[0]; if (!id) { console.error('Usage: docfast-support view '); process.exit(1); } const res = await apiRequest('GET', `/conversations/${id}`); if (res.status !== 200) { console.error('Error:', res.status, JSON.stringify(res.data)); process.exit(1); } const c = res.data; console.log(`Ticket #${c.id}: ${c.subject}`); console.log(`Status: ${c.status} | Customer: ${c.customer?.email || 'unknown'} | Created: ${c.createdAt}`); console.log('---'); // Get threads (messages) — must use embed=threads on conversation endpoint const tRes = await apiRequest('GET', `/conversations/${id}?embed=threads`); if (tRes.status === 200) { const threads = tRes.data._embedded?.threads || []; for (const t of threads) { const from = t.createdBy?.email || t.customer?.email || 'system'; const type = t.type || 'message'; // customer|message|note const date = t.createdAt?.replace('T', ' ').slice(0, 16) || '-'; console.log(`\n[${date}] ${type.toUpperCase()} from ${from}:`); // Strip HTML tags for readability const body = (t.body || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim(); console.log(body); } } } async function cmdReply(args) { const id = getFlag(args, '--ticket'); const message = getFlag(args, '--message'); const draft = args.includes('--draft'); const status = getFlag(args, '--status'); // active|pending|closed if (!id || !message) { console.error('Usage: docfast-support reply --ticket --message "..." [--draft] [--status active|pending|closed]'); process.exit(1); } const body = { type: draft ? 'note' : 'message', text: message, body: message, user: 6, status: status || (draft ? undefined : 'active'), }; // If draft, create as internal note (visible to agents only) if (draft) { body.type = 'note'; } const res = await apiRequest('POST', `/conversations/${id}/threads`, body); if (res.status === 201 || res.status === 200) { console.log(draft ? `Draft note added to ticket #${id}` : `Reply sent to ticket #${id}`); if (status) console.log(`Status set to: ${status}`); } else { console.error('Error:', res.status, JSON.stringify(res.data)); process.exit(1); } } async function cmdNeedsReply(args) { // Show only tickets assigned to the AI agent (franz.hubert@docfast.dev) where the last non-note thread is from a customer const AI_AGENT_EMAIL = 'franz.hubert@docfast.dev'; const page = getFlag(args, '--page') || '1'; let endpoint = `/conversations?page=${page}&status=active`; if (MAILBOX_ID) endpoint += `&mailboxId=${MAILBOX_ID}`; const res = await apiRequest('GET', endpoint); if (res.status !== 200) { console.error('Error:', res.status, JSON.stringify(res.data)); process.exit(1); } const conversations = res.data._embedded?.conversations || []; if (conversations.length === 0) { console.log('No tickets need a reply.'); return; } const needsReply = []; for (const c of conversations) { // Only tickets assigned to the AI agent or unassigned const assignee = c.assignee?.email || ''; if (assignee && assignee.toLowerCase() !== AI_AGENT_EMAIL.toLowerCase()) continue; const tRes = await apiRequest('GET', `/conversations/${c.id}?embed=threads`); if (tRes.status !== 200) { console.error(` Ticket ${c.id}: API returned ${tRes.status}`); continue; } const threads = (tRes.data._embedded?.threads || []) .filter(t => t.type !== 'note' && t.type !== 'lineitem'); if (threads.length === 0) { continue; } const lastThread = threads[0]; // FreeScout returns threads in reverse chronological order (newest first) // type 'customer' = from customer, 'message' = from agent if (lastThread.type === 'customer') { needsReply.push(c); } } if (needsReply.length === 0) { console.log('No tickets need a reply.'); return; } console.log('ID\tSUBJECT\tCUSTOMER\tCREATED'); for (const c of needsReply) { const customer = c.customer?.email || c.customer?.firstName || 'unknown'; const created = c.createdAt?.split('T')[0] || '-'; console.log(`${c.id}\t${c.subject}\t${customer}\t${created}`); } console.log(`\n${needsReply.length} ticket(s) need a reply.`); } async function cmdClose(args) { const id = args[0]; if (!id) { console.error('Usage: docfast-support close '); process.exit(1); } const res = await apiRequest('PUT', `/conversations/${id}`, { status: 'closed' }); if (res.status === 200 || res.status === 204) { console.log(`Ticket #${id} closed.`); } else { console.error('Error:', res.status, JSON.stringify(res.data)); process.exit(1); } } async function cmdStats() { const statuses = ['active', 'pending', 'closed']; console.log('STATUS\tCOUNT'); for (const s of statuses) { let endpoint = `/conversations?status=${s}&page=1`; if (MAILBOX_ID) endpoint += `&mailboxId=${MAILBOX_ID}`; const res = await apiRequest('GET', endpoint); const total = res.data?.page?.total || 0; console.log(`${s}\t${total}`); } } async function cmdMailboxes() { const res = await apiRequest('GET', '/mailboxes'); if (res.status !== 200) { console.error('Error:', res.status, JSON.stringify(res.data)); process.exit(1); } const mailboxes = res.data._embedded?.mailboxes || []; console.log('ID\tNAME\tEMAIL'); for (const m of mailboxes) { console.log(`${m.id}\t${m.name}\t${m.email || '-'}`); } } // --- Helpers --- function getFlag(args, flag) { const idx = args.indexOf(flag); if (idx === -1) return null; return args[idx + 1] || null; } // --- Main --- const [cmd, ...args] = process.argv.slice(2); const commands = { tickets: cmdTickets, 'needs-reply': cmdNeedsReply, view: cmdView, reply: cmdReply, close: cmdClose, stats: cmdStats, mailboxes: cmdMailboxes, }; if (!cmd || !commands[cmd]) { console.log(`DocFast Support — FreeScout ticket management Usage: docfast-support [args] Commands: tickets [--status active|pending|closed|spam] List tickets (default: active) needs-reply Tickets waiting for agent reply view View ticket with all messages reply --ticket --message "..." Send reply to customer [--draft] Save as internal note instead [--status active|pending|closed] Set ticket status after reply close Close a ticket stats Ticket counts by status mailboxes List mailboxes (find your mailbox ID) Env vars (in .credentials/docfast.env): FREESCOUT_URL FreeScout instance URL FREESCOUT_API_KEY API key FREESCOUT_MAILBOX_ID Mailbox ID for filtering (optional)`); process.exit(0); } commands[cmd](args).catch(e => { console.error(e.message); process.exit(1); });