// Jira attachments + Webex CC transcript conversion. // Downloads happen via downloadClient (own retry policy); uploads use jiraClient. import FormData from 'form-data'; import logger from '../../utilities/logger.js'; import { jiraClient, downloadClient } from './client.js'; /** * Shared helper: POST a Buffer as multipart attachment to the core Jira * attachments endpoint. * - Uses jiraClient (correct base + auth). * - Spreads form.getHeaders() so boundary is set. * - Cleans any charset from Content-Type (prevents 415). * - Retry loop only around the API call (download is caller's job). * - Fail-fast on 401/403 (scope/perms). */ async function attachBufferToJira(jiraKey, fileBuffer, fileName) { for (let attempt = 1; attempt <= 3; attempt++) { try { const form = new FormData(); form.append('file', fileBuffer, fileName); const formHeaders = form.getHeaders(); if (formHeaders['content-type']) { formHeaders['content-type'] = formHeaders['content-type'].replace(/;\s*charset=[^;]*/i, ''); } const uploadPath = `/rest/api/3/issue/${jiraKey}/attachments`; await jiraClient.post(uploadPath, form, { headers: { 'X-Atlassian-Token': 'no-check', ...formHeaders }, timeout: 15000 }); logger.info('File attached successfully', { jiraKey, fileName, attempt }); return; } catch (err) { const status = err.response?.status; logger.error('Jira file attach attempt failed', { jiraKey, fileName, attempt, status, responseData: err.response?.data, responseHeaders: err.response?.headers ? Object.fromEntries( Object.entries(err.response.headers).filter(([k]) => !k.toLowerCase().includes('auth')) ) : undefined }); if (status === 401 || status === 403) { throw err; } if (attempt === 3) throw err; await new Promise(r => setTimeout(r, attempt * 1500)); } } } /** * Download a file from the given URL (S3 pre-signed) *once* and attach using * the core Jira attachments API. Download happens outside the retry loop * because the signed URL expires (~1800s). */ export async function attachFileToJira(jiraKey, fileUrl, fileName) { let fileBuffer; try { const dl = await downloadClient.get(fileUrl, { responseType: 'arraybuffer' }); fileBuffer = Buffer.from(dl.data); } catch (dlErr) { logger.error('Failed to download file from S3 for attachment (URL likely expired on replay)', { jiraKey, fileName, url: fileUrl, status: dlErr.response?.status, message: dlErr.message }); throw dlErr; } await attachBufferToJira(jiraKey, fileBuffer, fileName); } /** * Convert a Webex-style JSON transcript to human-readable text. * Returns null if the shape isn't recognized. */ function formatTranscriptToHumanReadable(data) { if (!data || !Array.isArray(data.responseContents)) return null; const lines = []; lines.push(`Transcript`); if (data.interactionId) lines.push(`Interaction ID: ${data.interactionId}`); if (data.languageCode) lines.push(`Language: ${data.languageCode}`); lines.push(''); for (const entry of data.responseContents) { const res = entry.recognitionResult; if (!res || !res.alternatives || !res.alternatives[0]) continue; const role = (res.role || 'UNKNOWN').toUpperCase(); const alt = res.alternatives[0]; const transcript = (alt.transcript || '').trim(); if (!transcript) continue; let ts = ''; const words = alt.words || []; if (words.length > 0) { const start = words[0].start_time || {}; const totalSec = (start.seconds || 0) + Math.floor((start.nanos || 0) / 1e9); const min = Math.floor(totalSec / 60); const sec = Math.floor(totalSec % 60); ts = `[${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}] `; } lines.push(`${ts}${role}: ${transcript}`); } return lines.join('\n'); } async function fetchAndConvertTranscript(url) { try { const resp = await downloadClient.get(url, { timeout: 10000 }); return formatTranscriptToHumanReadable(resp.data); } catch (e) { logger.warn(`Failed to fetch/convert transcript: ${e.message}`); return null; } } /** * Download the JSON transcript, convert to human-readable text, and attach as * `-readable.txt`. Failures are swallowed so they don't mark the * original JSON attach as failed. */ export async function attachReadableTranscript(jiraKey, transcriptUrl, originalFileName = null) { const readable = await fetchAndConvertTranscript(transcriptUrl); if (!readable) { logger.warn('Readable transcript conversion yielded no content (check transcript JSON shape or URL)', { jiraKey }); return false; } const base = (originalFileName || `transcript-${jiraKey}`).replace(/\.json$/i, ''); const fileName = `${base}-readable.txt`; try { await attachBufferToJira(jiraKey, Buffer.from(readable, 'utf8'), fileName); return true; } catch (err) { logger.error('Readable transcript attach failed (non-fatal)', { jiraKey, fileName, error: err.response?.data?.message || err.message, status: err.response?.status }); return false; } }