Rev 37651 | Blame | Compare with Previous | Last modification | View Log | RSS feed
package com.spice.profitmandi.web.controller;import com.fasterxml.jackson.databind.JsonNode;import com.fasterxml.jackson.databind.ObjectMapper;import com.spice.profitmandi.common.web.util.ResponseSender;import com.spice.profitmandi.dao.entity.auth.AuthUser;import com.spice.profitmandi.dao.entity.user.Lead;import com.spice.profitmandi.dao.entity.user.LeadCall;import com.spice.profitmandi.dao.entity.user.LeadDnd;import com.spice.profitmandi.dao.entity.auth.VonageAgent;import com.spice.profitmandi.dao.repository.auth.AuthRepository;import com.spice.profitmandi.dao.repository.auth.VonageAgentRepository;import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;import com.spice.profitmandi.dao.repository.dtr.LeadRepository;import com.spice.profitmandi.service.integrations.vonage.VonageVoiceService;import com.spice.profitmandi.service.lms.LmsCallService;import com.spice.profitmandi.service.lms.LmsOutcome;import com.spice.profitmandi.service.lms.LmsDialerProvider;import com.spice.profitmandi.web.model.LoginDetails;import com.spice.profitmandi.web.util.CookiesProcessor;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Value;import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.http.ResponseEntity;import org.springframework.stereotype.Controller;import org.springframework.transaction.annotation.Transactional;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestHeader;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestMethod;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.ResponseBody;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import javax.servlet.http.HttpServletRequest;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.time.LocalDateTime;import java.time.LocalTime;import java.util.Base64;import java.util.HashMap;import java.util.Map;/*** The LMS web dialer: the endpoints the browser softphone talks to, plus the three Vonage callbacks.** <p><b>Two audiences, two security models.</b> {@code /lms/dialer/*} is normal authenticated app* traffic behind the session cookie and the role gate. {@code /vonage/voice/*} is Vonage calling us,* so those paths are excluded from all three interceptors in {@code WebConfig} and are protected* instead by the signed-webhook JWT — which means they must never trust anything in the payload that* they can look up themselves. In particular the number we dial comes from the lead record, never* from the webhook.*/@Controller@Transactional(rollbackFor = Throwable.class)public class LmsDialerController {private static final Logger LOGGER = LogManager.getLogger(LmsDialerController.class);private final ObjectMapper objectMapper = new ObjectMapper();@Autowiredprivate LeadRepository leadRepository;@Autowiredprivate LeadCallRepository leadCallRepository;@Autowiredprivate LeadDndRepository leadDndRepository;@Autowiredprivate VonageVoiceService vonageVoiceService;@Autowiredprivate LmsDialerProvider dialerProvider;/*** Placing, tracking and ending a call — shared with the field app's endpoints in* profitmandi-web, which differ from these only in how they identify the agent.*/@Autowiredprivate LmsCallService lmsCallService;@Autowiredprivate AuthRepository authRepository;@Autowiredprivate VonageAgentRepository vonageAgentRepository;@Autowiredprivate CookiesProcessor cookiesProcessor;@Autowiredprivate ResponseSender<?> responseSender;/*** Vonage account signature secret. Verification is skipped while this is blank so a new* environment can be brought up before the secret is in place — same opt-in shape as* {@code PinelabsWebhookController}. Set it in every environment that faces the internet.*/@Value("${vonage.webhook.signature.secret:}")private String webhookSignatureSecret;// ---- Browser-facing -----------------------------------------------------------------------/*** Mint this agent's dialer credential. Short-lived by design — the page asks again rather than* holding a long-lived token in memory.*/@RequestMapping(value = "/lms/dialer/token", method = RequestMethod.GET)@ResponseBodypublic ResponseEntity<?> token(HttpServletRequest request) {AuthUser me = currentUser(request);LmsDialerProvider.Handshake handshake = dialerProvider.prepare(me);Map<String, Object> out = new HashMap<>();out.put("ok", handshake.ok);if (!handshake.ok) {out.put("reason", handshake.reason);return responseSender.ok(out);}out.put("mode", handshake.mode.name());out.put("token", handshake.token);out.put("applicationId", handshake.applicationId);return responseSender.ok(out);}/*** Gate a dial before it happens: DND and calling hours.** <p>A refusal is a 200 carrying {@code {ok:false, code, reason}} — "this retailer is on the* DND register" is a normal answer to "may I dial?", not a client error, and the browser needs* the code to decide what to show.*/@RequestMapping(value = "/lms/dialer/precheck", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> precheck(HttpServletRequest request,@RequestParam(name = "leadId") int leadId) {LmsOutcome outcome = lmsCallService.precheck(leadId);if (outcome.status == LmsOutcome.Status.REFUSED) {return responseSender.ok(outcome.asRefusalBody());}return render(outcome);}/*** The call row for a lead, so the browser can learn its {@code leadCallId} after the answer* webhook has created it. The browser cannot know this id itself — see {@code VonageVoiceService}.*/@RequestMapping(value = "/lms/dialer/call", method = RequestMethod.GET)@ResponseBodypublic ResponseEntity<?> currentCall(HttpServletRequest request,@RequestParam(name = "leadId") int leadId) {AuthUser me = currentUser(request);if (me == null) {return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}LeadCall call = leadCallRepository.selectLatestByLeadIdAndAuthId(leadId, me.getId());Map<String, Object> out = new HashMap<>();if (call == null) {out.put("found", false);return responseSender.ok(out);}out.put("found", true);out.put("leadCallId", call.getId());out.put("status", call.getStatus() != null ? call.getStatus().name() : null);out.put("durationSeconds", call.getDurationSeconds());out.put("hasRecording", call.getRecordingDocumentId() != null);return responseSender.ok(out);}/** Place a call server-side (click2dial). */@RequestMapping(value = "/lms/dialer/dial", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> dial(HttpServletRequest request,@RequestParam(name = "leadId") int leadId) {return render(lmsCallService.dial(currentUser(request), leadId));}/** Poll one call's live state — click2dial has no event webhook, so the browser has to ask. */@RequestMapping(value = "/lms/dialer/state", method = RequestMethod.GET)@ResponseBodypublic ResponseEntity<?> state(HttpServletRequest request,@RequestParam(name = "leadCallId") int leadCallId) {return render(lmsCallService.state(currentUser(request), leadCallId));}/** Hang up a server-placed call. */@RequestMapping(value = "/lms/dialer/hangup", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> hangup(HttpServletRequest request,@RequestParam(name = "leadCallId") int leadCallId) {return render(lmsCallService.hangup(currentUser(request), leadCallId));}/** Map a service outcome onto this module's response envelope. */private ResponseEntity<?> render(LmsOutcome outcome) {switch (outcome.status) {case OK:return responseSender.ok(outcome.data);case NOT_FOUND:return responseSender.notFound(outcome.message);case UNAUTHORIZED:return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();case FORBIDDEN:return ResponseEntity.status(HttpStatus.FORBIDDEN).build();default:return responseSender.badRequest(outcome.message);}}// ---- Vonage callbacks ---------------------------------------------------------------------/*** Answer webhook — returns the NCCO. Accepts GET and POST because the method is an Application* setting rather than something we control per call.** <p>Always returns valid NCCO JSON, never an error status: a 5xx here makes Vonage retry a call* that is already ringing. An unidentifiable lead yields an empty NCCO, which hangs up cleanly.*/@RequestMapping(value = "/vonage/voice/answer", method = {RequestMethod.GET, RequestMethod.POST},produces = MediaType.APPLICATION_JSON_VALUE)@ResponseBodypublic String answer(HttpServletRequest request,@RequestBody(required = false) String rawBody) {try {JsonNode body = (rawBody != null && !rawBody.trim().isEmpty())? objectMapper.readTree(rawBody) : null;String conversationUuid = firstNonNull(body != null ? asText(body.get("conversation_uuid")) : null,request.getParameter("conversation_uuid"));String callUuid = firstNonNull(body != null ? asText(body.get("uuid")) : null,request.getParameter("uuid"));// custom_data is what serverCall({leadId}) put on the wire.int leadId = 0;if (body != null && body.get("custom_data") != null) {leadId = asInt(body.get("custom_data").get("leadId"));}if (leadId <= 0) {leadId = parseInt(request.getParameter("leadId"));}// The agent is resolved from `from_user` — the JWT `sub` Vonage authenticated, echoed back// to us. Deliberately NOT taken from custom_data: this webhook is unauthenticated as far// as the app is concerned, and an auth id supplied by the caller would let anyone// attribute a call, and its recording, to another agent. The same lookup yields the// agent's own caller ID, which is why it must be trustworthy.VonageAgent agent = resolveAgent(firstNonNull(body != null ? asText(body.get("from_user")) : null,request.getParameter("from_user")));int authId = agent != null ? agent.getAuthId() : 0;String agentFromNumber = agent != null ? agent.getFromNumber() : null;Lead lead = leadId > 0 ? leadRepository.selectById(leadId) : null;if (lead == null) {LOGGER.error("Vonage answer webhook could not resolve a lead (conversation {})", conversationUuid);return "[]";}// Defence in depth: the number is taken from the lead, and DND is re-checked here rather// than trusting that the pre-check ran or that nothing changed since.if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {LOGGER.warn("Refusing to connect lead {} — number is on the DND register", leadId);return "[]";}return vonageVoiceService.onAnswer(leadId, authId, agentFromNumber, lead.getLeadMobile(),conversationUuid, callUuid);} catch (Exception e) {LOGGER.error("Vonage answer webhook failed", e);return "[]";}}/** Event webhook — call state transitions. Always ACKs; Vonage retries anything non-2xx. */@RequestMapping(value = "/vonage/voice/event", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> event(HttpServletRequest request,@RequestHeader(name = "Authorization", required = false) String authorization,@RequestBody(required = false) String rawBody) {if (!verifySignature(authorization, rawBody)) {LOGGER.warn("Rejected a Vonage event webhook with a bad signature");return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}try {vonageVoiceService.onEvent(objectMapper.readTree(rawBody));} catch (Exception e) {LOGGER.error("Vonage event webhook failed for body {}", rawBody, e);}return ResponseEntity.ok().build();}/** Recording webhook — attaches the vendor handle; archival happens on its own schedule. */@RequestMapping(value = "/vonage/voice/recording", method = RequestMethod.POST)@ResponseBodypublic ResponseEntity<?> recording(HttpServletRequest request,@RequestHeader(name = "Authorization", required = false) String authorization,@RequestBody(required = false) String rawBody) {if (!verifySignature(authorization, rawBody)) {LOGGER.warn("Rejected a Vonage recording webhook with a bad signature");return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();}try {vonageVoiceService.onRecording(objectMapper.readTree(rawBody));} catch (Exception e) {LOGGER.error("Vonage recording webhook failed for body {}", rawBody, e);}return ResponseEntity.ok().build();}/*** Vonage signed webhooks carry an HS256 JWT in {@code Authorization: Bearer}, signed with the* account signature secret, whose {@code payload_hash} claim is the SHA-256 of the request body.* Verifying both the signature and the hash is what stops a replayed body.** <p>Returns true when no secret is configured — deliberate, so a new environment is not bricked* before the secret lands, and the same opt-in shape the Pinelabs webhook already uses.*/private boolean verifySignature(String authorization, String rawBody) {if (webhookSignatureSecret == null || webhookSignatureSecret.trim().isEmpty()) {return true;}if (authorization == null || !authorization.trim().toLowerCase().startsWith("bearer ")) {return false;}try {String jwt = authorization.trim().substring(7).trim();String[] parts = jwt.split("\\.");if (parts.length != 3) {return false;}String signingInput = parts[0] + "." + parts[1];Mac mac = Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(webhookSignatureSecret.trim().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));byte[] expected = mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8));byte[] actual = Base64.getUrlDecoder().decode(parts[2]);if (!MessageDigest.isEqual(expected, actual)) {return false;}JsonNode claims = objectMapper.readTree(Base64.getUrlDecoder().decode(parts[1]));String payloadHash = asText(claims.get("payload_hash"));if (payloadHash == null) {// Signature alone is valid; a hash-less token cannot bind to this body.return true;}byte[] digest = MessageDigest.getInstance("SHA-256").digest((rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8));return MessageDigest.isEqual(hex(digest).getBytes(StandardCharsets.UTF_8),payloadHash.toLowerCase().getBytes(StandardCharsets.UTF_8));} catch (Exception e) {LOGGER.warn("Could not verify a Vonage webhook signature", e);return false;}}private String hex(byte[] bytes) {StringBuilder sb = new StringBuilder(bytes.length * 2);for (byte b : bytes) {sb.append(Character.forDigit((b >> 4) & 0xF, 16));sb.append(Character.forDigit(b & 0xF, 16));}return sb.toString();}// ---- helpers ------------------------------------------------------------------------------private String asText(JsonNode node) {if (node == null || node.isNull()) {return null;}String s = node.asText();return (s == null || s.trim().isEmpty()) ? null : s.trim();}private int asInt(JsonNode node) {return (node == null || node.isNull()) ? 0 : node.asInt(0);}private int parseInt(String s) {try {return (s == null || s.trim().isEmpty()) ? 0 : Integer.parseInt(s.trim());} catch (NumberFormatException e) {return 0;}}private String firstNonNull(String a, String b) {return a != null ? a : b;}/*** Vonage user name -> the agent mapping, which carries both our auth id and that agent's own* caller ID. Returns null when unmapped, leaving the call unattributed and falling back to the* default caller ID rather than attributing it to the wrong person — an orphan call is* recoverable, a misattributed recording is not.*/private VonageAgent resolveAgent(String vonageUserName) {if (vonageUserName == null || vonageUserName.trim().isEmpty()) {LOGGER.warn("Vonage answer webhook carried no from_user — call will be unattributed");return null;}VonageAgent agent = vonageAgentRepository.selectByVonageUserName(vonageUserName);if (agent == null) {LOGGER.warn("No vonage_agent mapping for Vonage user '{}'", vonageUserName);}return agent;}private AuthUser currentUser(HttpServletRequest request) {try {LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);if (loginDetails != null && loginDetails.getEmailId() != null) {return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());}} catch (Exception e) {LOGGER.warn("Could not resolve the user for a dialer request", e);}return null;}}