Extends the single-Novi codebase into a multi-bot mass-messenger where each bot has its own token, avatar, label, and per-user authorization. - Secrets moved out of config.json: per-bot tokens in gitignored config/botTokens.json (with enabled flag), service-account OAuth in gitignored config/token.json (rewritten by refresh cron), integration and Google keys in .env. - Single Webex integration handles OAuth for all bots via a per-app redirect URI derived from OAUTH_CALLBACK_URL_TEMPLATE. - New requireBot middleware and getBotConfig helper reject requests for unknown or disabled bots at the /CollabCentral/:app boundary. - New /info endpoint plus dynamic frontend loading (sendMessage, monitorJobs) so pages self-describe per bot, including bot avatar fetched from Webex /people/me at startup. - Job draft state keyed by cookieId + appName so each bot has its own building queue; job list/detail endpoints filter by appName so users only see jobs from bots they are authorized on. - New jobDetail page for a readable per-job view; completed jobs are retained for 30 days by the cleanup cron. - Completion adaptive cards use per-bot avatar and label. - Miscellaneous fixes: off-by-two in the send loop, removed three dead send/process variants, added defensive init for jobs.* on load, dropped the deprecated crypto npm shim, and cleaned up stray logger labels and typos. Co-authored-by: Cursor <cursoragent@cursor.com>
179 lines
6.3 KiB
JavaScript
179 lines
6.3 KiB
JavaScript
var pathArray = window.location.pathname.split('/');
|
|
var appName = pathArray[2];
|
|
|
|
var urlParams = new URLSearchParams(window.location.search);
|
|
var jobId = urlParams.get('jobId');
|
|
|
|
if (!getCookie("id")) {
|
|
var randomNumber = Math.random().toString().substring(2);
|
|
fetch('/CollabCentral/' + appName + '/authUrl')
|
|
.then(res => res.text())
|
|
.then(res => { window.location.href = res + randomNumber; });
|
|
} else {
|
|
bootstrap();
|
|
}
|
|
|
|
function bootstrap() {
|
|
fetch('/CollabCentral/' + appName + '/info')
|
|
.then(res => res.json())
|
|
.then(info => {
|
|
document.title = info.label + " — Job #" + (jobId || "?");
|
|
document.getElementById("appLabel").innerHTML = info.label;
|
|
document.getElementById("name").innerHTML = getCookie("displayName") || "";
|
|
document.getElementById("appName").href = info.faviconUrl;
|
|
document.getElementById("jobLink").href = "/CollabCentral/" + appName + "/monitorJobs.html";
|
|
|
|
var appIcon = document.getElementById("appIcon");
|
|
appIcon.src = info.iconUrl;
|
|
appIcon.style.maxHeight = "150px";
|
|
appIcon.style.maxWidth = "150px";
|
|
|
|
if (!info.authorized) {
|
|
document.getElementById("unauthorizedPanel").classList.remove("hidden");
|
|
return;
|
|
}
|
|
loadJobDetail();
|
|
})
|
|
.catch(err => console.error("Failed to load /info:", err));
|
|
}
|
|
|
|
function loadJobDetail() {
|
|
if (!jobId) {
|
|
document.getElementById("notFoundPanel").classList.remove("hidden");
|
|
return;
|
|
}
|
|
fetch('/CollabCentral/' + appName + '/jobs/detail/' + encodeURIComponent(jobId))
|
|
.then(res => {
|
|
if (res.status === 404) {
|
|
document.getElementById("notFoundPanel").classList.remove("hidden");
|
|
return null;
|
|
}
|
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
return res.json();
|
|
})
|
|
.then(job => {
|
|
if (!job) return;
|
|
renderJob(job);
|
|
document.getElementById("detailPanel").classList.remove("hidden");
|
|
})
|
|
.catch(err => {
|
|
console.error("Failed to load job detail:", err);
|
|
document.getElementById("notFoundPanel").classList.remove("hidden");
|
|
});
|
|
}
|
|
|
|
function renderJob(job) {
|
|
document.getElementById("jobTitle").textContent = "Job #" + (job.jobId || "?");
|
|
var subtitle = "Sent by " + (job.senderDisplayName || "unknown");
|
|
if (job.startTime) subtitle += " · started " + formatDate(job.startTime);
|
|
document.getElementById("subtitle").textContent = subtitle;
|
|
|
|
var dl = document.getElementById("summaryGrid");
|
|
dl.innerHTML = "";
|
|
var stats = job.stats || {};
|
|
var rows = [
|
|
["Job ID", job.jobId],
|
|
["Submitted by", job.senderDisplayName],
|
|
["Scheduled for", job.scheduledFor ? formatDate(job.scheduledFor) : "Sent immediately"],
|
|
["Started", formatDate(job.startTime)],
|
|
["Completed", job.endTime ? formatDate(job.endTime) : "In progress"],
|
|
["Total duration", stats.totalTime],
|
|
["Webex time", stats.webexTime],
|
|
["Average per message", stats.averageTime],
|
|
["Recipients", (job.memberList || []).length],
|
|
["Delivered", formatSuccess(stats)]
|
|
];
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var key = rows[i][0], val = rows[i][1];
|
|
if (val === undefined || val === null || val === "") continue;
|
|
var dt = document.createElement("dt"); dt.textContent = key;
|
|
var dd = document.createElement("dd"); dd.textContent = val;
|
|
dl.appendChild(dt); dl.appendChild(dd);
|
|
}
|
|
|
|
var preview = document.getElementById("messagePreview");
|
|
var msg = (job.message && job.message.english) || job.message || {};
|
|
if (msg.html) {
|
|
preview.innerHTML = msg.html;
|
|
} else if (msg.markdown) {
|
|
preview.textContent = msg.markdown;
|
|
} else if (msg.text) {
|
|
preview.textContent = msg.text;
|
|
} else if (typeof msg.raw === "string") {
|
|
preview.textContent = msg.raw;
|
|
} else {
|
|
preview.innerHTML = "<em>No message content available.</em>";
|
|
}
|
|
|
|
var members = job.memberList || [];
|
|
document.getElementById("recipientCount").textContent = members.length;
|
|
|
|
$('#recipients').dataTable({
|
|
data: members.map(formatRecipientRow),
|
|
columns: [
|
|
{ data: 'displayName' },
|
|
{ data: 'email' },
|
|
{ data: 'status' },
|
|
{ data: 'time' },
|
|
{ data: 'error' }
|
|
],
|
|
pageLength: 50,
|
|
lengthMenu: [25, 50, 100, 250],
|
|
order: [[2, 'asc']],
|
|
searching: true,
|
|
info: true,
|
|
paging: true
|
|
});
|
|
}
|
|
|
|
function formatRecipientRow(m) {
|
|
var result = m.results || null;
|
|
var status;
|
|
var email = "—";
|
|
var err = "";
|
|
|
|
if (!result) {
|
|
status = '<span class="pill pending">Pending</span>';
|
|
} else if (result.id && !result.message && !result.errors) {
|
|
status = '<span class="pill ok">Delivered</span>';
|
|
email = result.toPersonEmail || email;
|
|
} else {
|
|
status = '<span class="pill err">Failed</span>';
|
|
email = result.toPersonEmail || email;
|
|
if (result.message) err = result.message;
|
|
else if (result.errors && result.errors.length) {
|
|
err = result.errors.map(function (e) { return e.description || e.code || JSON.stringify(e); }).join("; ");
|
|
}
|
|
}
|
|
|
|
return {
|
|
displayName: m.displayName || "—",
|
|
email: email,
|
|
status: status,
|
|
time: m.msgTime ? (m.msgTime / 1000).toFixed(2) + " s" : "—",
|
|
error: err
|
|
};
|
|
}
|
|
|
|
function formatSuccess(stats) {
|
|
if (!stats || stats.succeededMsgs === undefined) return null;
|
|
var pct = (typeof stats.succeededMsgPct === "number") ? stats.succeededMsgPct.toFixed(1) : stats.succeededMsgPct;
|
|
return stats.succeededMsgs + " / " + stats.totalMsgs + " (" + pct + "%)";
|
|
}
|
|
|
|
function formatDate(d) {
|
|
if (!d) return null;
|
|
try { return new Date(d).toLocaleString(); } catch (e) { return d; }
|
|
}
|
|
|
|
function getCookie(cname) {
|
|
let name = cname + "=";
|
|
let decodedCookie = decodeURIComponent(document.cookie);
|
|
let ca = decodedCookie.split(';');
|
|
for (let i = 0; i < ca.length; i++) {
|
|
let c = ca[i];
|
|
while (c.charAt(0) == ' ') c = c.substring(1);
|
|
if (c.indexOf(name) == 0) return c.substring(name.length, c.length);
|
|
}
|
|
return "";
|
|
}
|