Connect-Desk-Project/probe.js

281 lines
8.5 KiB
JavaScript

// Main-world probe injected into every account document (top page and CCP
// iframes). Serialized with toString(), so this function must be fully
// self-contained — no requires, no closed-over values.
//
// Jobs: detect the signed-in agent, detect contacts arriving/leaving, run
// Available/Offline commands, and force spellcheck on chat composers.
function mainWorldProbe() {
if (window.__cdProbe) return;
window.__cdProbe = true;
const post = function (m) {
const payload = Object.assign({ __cdmsg: true }, m);
window.postMessage(payload, "*");
try {
if (window.top && window.top !== window) window.top.postMessage(payload, "*");
} catch (e) {}
};
// ---------- signed-in agent: name + auth ----------
let namePosted = "";
// Login-form furniture is not a name — "Remember username" once got saved as
// an agent name because the username field's class matched the selector.
const NOT_A_NAME = /remember|username|password|sign\s?in|log\s?in|forgot|welcome/i;
const postName = function (n) {
n = (n || "").trim();
if (!n || NOT_A_NAME.test(n)) return;
if (n !== namePosted) {
namePosted = n;
post({ type: "agent", name: n });
}
};
const looksLikeLogin = function () {
const t = (document.body && document.body.innerText) || "";
return /Please log in|Forgot Password/i.test(t) && t.length < 900;
};
function scrapeName() {
try {
const text = (document.body && document.body.innerText) || "";
if (looksLikeLogin()) return;
const patterns = [
/Welcome\s+([^\s\n][^\n]{0,60})/,
/Signed in as\s+([^\s\n][^\n]{0,60})/i,
/Logged in as\s+([^\s\n][^\n]{0,60})/i,
];
for (let i = 0; i < patterns.length; i++) {
const m = text.match(patterns[i]);
if (m) return postName(m[1]);
}
const el = document.querySelector(
'[data-testid*="user" i],[class*="userName" i],[class*="agentName" i]'
);
if (el && el.textContent) postName(el.textContent);
} catch (e) {}
}
// ---------- agent status control (bulk Available/Offline) ----------
function doSetStatus(kind) {
const c = window.connect;
const fail = function (error) {
post({ type: "status-result", kind: kind, ok: false, error: error });
};
if (!c || !c.Agent) {
// Connect lives in the CCP iframe. Frames without Streams stay quiet so
// a top-frame "not signed in" doesn't beat a successful iframe reply.
// Login pages (top, no iframes, no connect) still fail fast.
try {
if (window !== window.top) return;
if (document.querySelectorAll("iframe").length) return;
} catch (e) {
return;
}
return fail("not signed in");
}
let agent;
try {
agent = new c.Agent();
} catch (e) {
return fail("not signed in");
}
let target;
try {
const wanted = kind === "offline" ? c.AgentStateType.OFFLINE : c.AgentStateType.ROUTABLE;
target = (agent.getAgentStates() || []).find(function (s) {
return s.type === wanted;
});
} catch (e) {
return fail("states unavailable");
}
if (!target) return fail("no matching state");
try {
agent.setState(
target,
{
success: function () {
post({ type: "status-result", kind: kind, ok: true, state: target.name });
},
failure: function () {
fail("rejected by Connect");
},
},
{ enqueueNextState: true }
);
} catch (e) {
fail((e && e.message) || "error");
}
}
window.addEventListener("message", function (e) {
const d = e.data;
if (!d || d.__cdcmd !== true) return;
if (d.cmd === "set-status") doSetStatus(d.kind);
});
// ---------- Streams hookup ----------
let streams = false;
const seenContacts = {};
function handleContact(contact) {
let id;
try {
id = contact.getContactId();
} catch (e) {
return;
}
if (!id || seenContacts[id]) return;
seenContacts[id] = true;
let q = "";
try {
q = (contact.getQueue() && contact.getQueue().name) || "";
} catch (e) {}
const gone = function () {
delete seenContacts[id];
post({ type: "gone", contactId: id });
};
post({ type: "incoming", contactId: id, queue: q });
["onConnected", "onEnded", "onMissed", "onDestroy", "onAccepted"].forEach(function (ev) {
try {
contact[ev] && contact[ev](gone);
} catch (e) {}
});
}
function tryStreams() {
const c = window.connect;
if (!c || typeof c.contact !== "function" || typeof c.agent !== "function") return false;
try {
c.contact(function (contact) {
handleContact(contact);
});
} catch (e) {
return false;
}
streams = true;
try {
c.agent(function (agent) {
post({ type: "auth", signedIn: true });
try {
postName(agent.getName && agent.getName());
} catch (e) {}
let lastState = "";
const sendState = function () {
try {
const s = agent.getState();
const key = ((s && s.name) || "") + "|" + ((s && s.type) || "");
if (key === lastState) return;
lastState = key;
post({ type: "state", stateName: (s && s.name) || "", stateType: (s && s.type) || "" });
} catch (e) {}
};
sendState();
try {
agent.onStateChange(sendState);
} catch (e) {}
try {
agent.onRefresh(sendState);
} catch (e) {}
// Catch a chat that was already ringing before we hooked contact().
try {
const contacts = agent.getContacts && agent.getContacts();
if (contacts && contacts.length) {
for (let i = 0; i < contacts.length; i++) handleContact(contacts[i]);
}
} catch (e) {}
});
} catch (e) {}
post({ type: "detection", mode: "streams" });
return true;
}
setInterval(function () {
if (!streams) tryStreams();
}, 1000);
tryStreams();
// Name + login-page detection loop. Keeps going until the name lands, because
// sign-in can happen long after the page first loads.
let loginPosted = false;
const nameTimer = setInterval(function () {
if (!streams) {
if (looksLikeLogin()) {
if (!loginPosted) {
loginPosted = true;
post({ type: "auth", signedIn: false });
}
} else {
loginPosted = false;
}
}
scrapeName();
if (namePosted && streams) clearInterval(nameTimer);
}, 2500);
scrapeName();
// DOM incoming-chat watch — always on, even if Streams hooked.
// Agent Workspace shows "Incoming chat" / "Accept chat" in the page even
// when connect.contact() never fires. Skipping this once streams=true is
// why the sidebar stayed on "No waiting chats" while CCP was ringing.
const INCOMING_RE =
/incoming chat|incoming contact|accept chat|reject chat|customer is waiting|new chat|chat request/i;
let domSeen = false;
const scanIncoming = function () {
const hit = INCOMING_RE.test((document.body && document.body.innerText) || "");
if (hit && !domSeen) {
domSeen = true;
post({ type: "incoming", contactId: "dom-incoming", queue: "" });
} else if (!hit && domSeen) {
domSeen = false;
post({ type: "gone", contactId: "dom-incoming" });
}
};
try {
new MutationObserver(scanIncoming).observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
} catch (e) {}
setInterval(scanIncoming, 1500);
scanIncoming();
// Extra Streams-only fallback if the SDK never appears at all (login page).
setTimeout(function () {
if (streams) return;
post({ type: "detection", mode: "dom" });
}, 20000);
// Connect's composer often sets spellcheck="false". Force it back on so
// Chromium's checker and our context-menu suggestions can run.
document.addEventListener(
"focusin",
function (e) {
const t = e.target;
if (!t) return;
const tag = (t.tagName || "").toUpperCase();
const type = (t.type || "").toLowerCase();
if (type === "password" || type === "hidden") return;
if (t.isContentEditable || tag === "TEXTAREA" || tag === "INPUT") {
try {
t.spellcheck = true;
t.setAttribute("spellcheck", "true");
} catch (err) {}
}
},
true
);
}
module.exports = {
mainWorldProbe,
probeScript: `(${mainWorldProbe.toString()})();`,
};