| 37651 |
vikas |
1 |
package com.spice.profitmandi.web.controller;
|
|
|
2 |
|
|
|
3 |
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
4 |
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
5 |
import com.spice.profitmandi.common.web.util.ResponseSender;
|
|
|
6 |
import com.spice.profitmandi.dao.entity.auth.AuthUser;
|
|
|
7 |
import com.spice.profitmandi.dao.entity.user.Lead;
|
|
|
8 |
import com.spice.profitmandi.dao.entity.user.LeadCall;
|
|
|
9 |
import com.spice.profitmandi.dao.entity.user.LeadDnd;
|
|
|
10 |
import com.spice.profitmandi.dao.entity.auth.VonageAgent;
|
|
|
11 |
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
|
|
|
12 |
import com.spice.profitmandi.dao.repository.auth.VonageAgentRepository;
|
|
|
13 |
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
|
|
|
14 |
import com.spice.profitmandi.dao.repository.dtr.LeadDndRepository;
|
|
|
15 |
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
|
|
|
16 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadCallProvider;
|
|
|
17 |
import com.spice.profitmandi.dao.enumuration.dtr.LeadCallStatus;
|
|
|
18 |
import com.spice.profitmandi.service.integrations.vonage.VonageVoiceService;
|
|
|
19 |
import com.spice.profitmandi.service.integrations.vonage.vbc.VbcDialerProvider;
|
|
|
20 |
import com.spice.profitmandi.service.integrations.vonage.vbc.VbcTelephonyService;
|
|
|
21 |
import com.spice.profitmandi.service.lms.LmsDialerProvider;
|
|
|
22 |
import com.spice.profitmandi.web.model.LoginDetails;
|
|
|
23 |
import com.spice.profitmandi.web.util.CookiesProcessor;
|
|
|
24 |
import org.apache.logging.log4j.LogManager;
|
|
|
25 |
import org.apache.logging.log4j.Logger;
|
|
|
26 |
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
27 |
import org.springframework.beans.factory.annotation.Value;
|
|
|
28 |
import org.springframework.http.HttpStatus;
|
|
|
29 |
import org.springframework.http.MediaType;
|
|
|
30 |
import org.springframework.http.ResponseEntity;
|
|
|
31 |
import org.springframework.stereotype.Controller;
|
|
|
32 |
import org.springframework.transaction.annotation.Transactional;
|
|
|
33 |
import org.springframework.web.bind.annotation.RequestBody;
|
|
|
34 |
import org.springframework.web.bind.annotation.RequestHeader;
|
|
|
35 |
import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
36 |
import org.springframework.web.bind.annotation.RequestMethod;
|
|
|
37 |
import org.springframework.web.bind.annotation.RequestParam;
|
|
|
38 |
import org.springframework.web.bind.annotation.ResponseBody;
|
|
|
39 |
|
|
|
40 |
import javax.crypto.Mac;
|
|
|
41 |
import javax.crypto.spec.SecretKeySpec;
|
|
|
42 |
import javax.servlet.http.HttpServletRequest;
|
|
|
43 |
import java.nio.charset.StandardCharsets;
|
|
|
44 |
import java.security.MessageDigest;
|
|
|
45 |
import java.time.LocalDateTime;
|
|
|
46 |
import java.time.LocalTime;
|
|
|
47 |
import java.util.Base64;
|
|
|
48 |
import java.util.HashMap;
|
|
|
49 |
import java.util.Map;
|
|
|
50 |
|
|
|
51 |
/**
|
|
|
52 |
* The LMS web dialer: the endpoints the browser softphone talks to, plus the three Vonage callbacks.
|
|
|
53 |
*
|
|
|
54 |
* <p><b>Two audiences, two security models.</b> {@code /lms/dialer/*} is normal authenticated app
|
|
|
55 |
* traffic behind the session cookie and the role gate. {@code /vonage/voice/*} is Vonage calling us,
|
|
|
56 |
* so those paths are excluded from all three interceptors in {@code WebConfig} and are protected
|
|
|
57 |
* instead by the signed-webhook JWT — which means they must never trust anything in the payload that
|
|
|
58 |
* they can look up themselves. In particular the number we dial comes from the lead record, never
|
|
|
59 |
* from the webhook.
|
|
|
60 |
*/
|
|
|
61 |
@Controller
|
|
|
62 |
@Transactional(rollbackFor = Throwable.class)
|
|
|
63 |
public class LmsDialerController {
|
|
|
64 |
|
|
|
65 |
private static final Logger LOGGER = LogManager.getLogger(LmsDialerController.class);
|
|
|
66 |
|
|
|
67 |
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
68 |
|
|
|
69 |
@Autowired
|
|
|
70 |
private LeadRepository leadRepository;
|
|
|
71 |
|
|
|
72 |
@Autowired
|
|
|
73 |
private LeadCallRepository leadCallRepository;
|
|
|
74 |
|
|
|
75 |
@Autowired
|
|
|
76 |
private LeadDndRepository leadDndRepository;
|
|
|
77 |
|
|
|
78 |
@Autowired
|
|
|
79 |
private VonageVoiceService vonageVoiceService;
|
|
|
80 |
|
|
|
81 |
@Autowired
|
|
|
82 |
private LmsDialerProvider dialerProvider;
|
|
|
83 |
|
|
|
84 |
/**
|
|
|
85 |
* Optional: only present when VBC is the active provider. Injected by type rather than through
|
|
|
86 |
* the interface because placing a call needs the agent's extension, which is VBC-specific.
|
|
|
87 |
*/
|
|
|
88 |
@Autowired(required = false)
|
|
|
89 |
private VbcDialerProvider vbcDialerProvider;
|
|
|
90 |
|
|
|
91 |
@Autowired(required = false)
|
|
|
92 |
private VbcTelephonyService vbcTelephonyService;
|
|
|
93 |
|
|
|
94 |
@Autowired
|
|
|
95 |
private AuthRepository authRepository;
|
|
|
96 |
|
|
|
97 |
@Autowired
|
|
|
98 |
private VonageAgentRepository vonageAgentRepository;
|
|
|
99 |
|
|
|
100 |
@Autowired
|
|
|
101 |
private CookiesProcessor cookiesProcessor;
|
|
|
102 |
|
|
|
103 |
@Autowired
|
|
|
104 |
private ResponseSender<?> responseSender;
|
|
|
105 |
|
|
|
106 |
/**
|
|
|
107 |
* Vonage account signature secret. Verification is skipped while this is blank so a new
|
|
|
108 |
* environment can be brought up before the secret is in place — same opt-in shape as
|
|
|
109 |
* {@code PinelabsWebhookController}. Set it in every environment that faces the internet.
|
|
|
110 |
*/
|
|
|
111 |
@Value("${vonage.webhook.signature.secret:}")
|
|
|
112 |
private String webhookSignatureSecret;
|
|
|
113 |
|
|
|
114 |
/** TRAI restricts commercial calling to 09:00–21:00. Configurable, but not by accident. */
|
|
|
115 |
@Value("${lms.dialer.calling.hours.start:9}")
|
|
|
116 |
private int callingHoursStart;
|
|
|
117 |
|
|
|
118 |
@Value("${lms.dialer.calling.hours.end:21}")
|
|
|
119 |
private int callingHoursEnd;
|
|
|
120 |
|
|
|
121 |
@Value("${lms.dialer.enforce.calling.hours:true}")
|
|
|
122 |
private boolean enforceCallingHours;
|
|
|
123 |
|
|
|
124 |
// ---- Browser-facing -----------------------------------------------------------------------
|
|
|
125 |
|
|
|
126 |
/**
|
|
|
127 |
* Mint this agent's dialer credential. Short-lived by design — the page asks again rather than
|
|
|
128 |
* holding a long-lived token in memory.
|
|
|
129 |
*/
|
|
|
130 |
/**
|
|
|
131 |
* How long a placed call may stay unseen in VBC's active-call list before it is written off.
|
|
|
132 |
* Generous enough to cover the gap between "VBC returned an id" and "the extension starts
|
|
|
133 |
* ringing", short enough that an agent is not left watching a dead progress bar.
|
|
|
134 |
*/
|
|
|
135 |
@Value("${vonage.vbc.initiate.grace.seconds:25}")
|
|
|
136 |
private int initiateGraceSeconds;
|
|
|
137 |
|
|
|
138 |
@RequestMapping(value = "/lms/dialer/token", method = RequestMethod.GET)
|
|
|
139 |
@ResponseBody
|
|
|
140 |
public ResponseEntity<?> token(HttpServletRequest request) {
|
|
|
141 |
AuthUser me = currentUser(request);
|
|
|
142 |
LmsDialerProvider.Handshake handshake = dialerProvider.prepare(me);
|
|
|
143 |
Map<String, Object> out = new HashMap<>();
|
|
|
144 |
out.put("ok", handshake.ok);
|
|
|
145 |
if (!handshake.ok) {
|
|
|
146 |
out.put("reason", handshake.reason);
|
|
|
147 |
return responseSender.ok(out);
|
|
|
148 |
}
|
|
|
149 |
out.put("mode", handshake.mode.name());
|
|
|
150 |
out.put("token", handshake.token);
|
|
|
151 |
out.put("applicationId", handshake.applicationId);
|
|
|
152 |
return responseSender.ok(out);
|
|
|
153 |
}
|
|
|
154 |
|
|
|
155 |
/**
|
|
|
156 |
* Gate a dial before it happens: DND and calling hours. Runs server-side because a check that
|
|
|
157 |
* only lives in the browser is not a check.
|
|
|
158 |
*/
|
|
|
159 |
@RequestMapping(value = "/lms/dialer/precheck", method = RequestMethod.POST)
|
|
|
160 |
@ResponseBody
|
|
|
161 |
public ResponseEntity<?> precheck(HttpServletRequest request,
|
|
|
162 |
@RequestParam(name = "leadId") int leadId) {
|
|
|
163 |
Map<String, Object> out = new HashMap<>();
|
|
|
164 |
Lead lead = leadRepository.selectById(leadId);
|
|
|
165 |
if (lead == null) {
|
|
|
166 |
return responseSender.notFound("Lead not found");
|
|
|
167 |
}
|
|
|
168 |
|
|
|
169 |
LeadDnd blocked = leadDndRepository.selectByMobile(lead.getLeadMobile());
|
|
|
170 |
if (blocked != null) {
|
|
|
171 |
out.put("ok", false);
|
|
|
172 |
out.put("code", "DND");
|
|
|
173 |
out.put("reason", "This retailer is on the do-not-call register");
|
|
|
174 |
return responseSender.ok(out);
|
|
|
175 |
}
|
|
|
176 |
if (enforceCallingHours && !withinCallingHours()) {
|
|
|
177 |
out.put("ok", false);
|
|
|
178 |
out.put("code", "OUTSIDE_HOURS");
|
|
|
179 |
out.put("reason", "Commercial calling is only permitted between "
|
|
|
180 |
+ callingHoursStart + ":00 and " + callingHoursEnd + ":00");
|
|
|
181 |
return responseSender.ok(out);
|
|
|
182 |
}
|
|
|
183 |
if (vonageVoiceService.toE164(lead.getLeadMobile()) == null) {
|
|
|
184 |
out.put("ok", false);
|
|
|
185 |
out.put("code", "NO_NUMBER");
|
|
|
186 |
out.put("reason", "This lead has no usable contact number");
|
|
|
187 |
return responseSender.ok(out);
|
|
|
188 |
}
|
|
|
189 |
|
|
|
190 |
out.put("ok", true);
|
|
|
191 |
out.put("leadId", leadId);
|
|
|
192 |
return responseSender.ok(out);
|
|
|
193 |
}
|
|
|
194 |
|
|
|
195 |
/**
|
|
|
196 |
* The call row for a lead, so the browser can learn its {@code leadCallId} after the answer
|
|
|
197 |
* webhook has created it. The browser cannot know this id itself — see {@code VonageVoiceService}.
|
|
|
198 |
*/
|
|
|
199 |
@RequestMapping(value = "/lms/dialer/call", method = RequestMethod.GET)
|
|
|
200 |
@ResponseBody
|
|
|
201 |
public ResponseEntity<?> currentCall(HttpServletRequest request,
|
|
|
202 |
@RequestParam(name = "leadId") int leadId) {
|
|
|
203 |
AuthUser me = currentUser(request);
|
|
|
204 |
if (me == null) {
|
|
|
205 |
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
|
|
206 |
}
|
|
|
207 |
LeadCall call = leadCallRepository.selectLatestByLeadIdAndAuthId(leadId, me.getId());
|
|
|
208 |
Map<String, Object> out = new HashMap<>();
|
|
|
209 |
if (call == null) {
|
|
|
210 |
out.put("found", false);
|
|
|
211 |
return responseSender.ok(out);
|
|
|
212 |
}
|
|
|
213 |
out.put("found", true);
|
|
|
214 |
out.put("leadCallId", call.getId());
|
|
|
215 |
out.put("status", call.getStatus() != null ? call.getStatus().name() : null);
|
|
|
216 |
out.put("durationSeconds", call.getDurationSeconds());
|
|
|
217 |
out.put("hasRecording", call.getRecordingDocumentId() != null);
|
|
|
218 |
return responseSender.ok(out);
|
|
|
219 |
}
|
|
|
220 |
|
|
|
221 |
/**
|
|
|
222 |
* Place a call server-side (click2dial). The agent's own VBC device rings first, then VBC dials
|
|
|
223 |
* the retailer.
|
|
|
224 |
*
|
|
|
225 |
* <p>Unlike the WebRTC path there is no answer webhook, so the {@code lead_call} row is created
|
|
|
226 |
* here — the server knows the lead, the agent and the call id in one place, which makes
|
|
|
227 |
* correlation trivial rather than something to reconstruct afterwards.
|
|
|
228 |
*/
|
|
|
229 |
@RequestMapping(value = "/lms/dialer/dial", method = RequestMethod.POST)
|
|
|
230 |
@ResponseBody
|
|
|
231 |
public ResponseEntity<?> dial(HttpServletRequest request,
|
|
|
232 |
@RequestParam(name = "leadId") int leadId) {
|
|
|
233 |
AuthUser me = currentUser(request);
|
|
|
234 |
if (me == null) {
|
|
|
235 |
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
|
|
236 |
}
|
|
|
237 |
if (vbcDialerProvider == null || vbcTelephonyService == null) {
|
|
|
238 |
return responseSender.badRequest("Server-placed calling is not available on this environment.");
|
|
|
239 |
}
|
|
|
240 |
Lead lead = leadRepository.selectById(leadId);
|
|
|
241 |
if (lead == null) {
|
|
|
242 |
return responseSender.notFound("Lead not found");
|
|
|
243 |
}
|
|
|
244 |
// Re-checked here rather than trusting that the pre-check ran, or that nothing changed since.
|
|
|
245 |
if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {
|
|
|
246 |
return responseSender.badRequest("This retailer is on the do-not-call register.");
|
|
|
247 |
}
|
|
|
248 |
if (enforceCallingHours && !withinCallingHours()) {
|
|
|
249 |
return responseSender.badRequest("Commercial calling is only permitted between "
|
|
|
250 |
+ callingHoursStart + ":00 and " + callingHoursEnd + ":00.");
|
|
|
251 |
}
|
|
|
252 |
String extension = vbcDialerProvider.endpointFor(me);
|
|
|
253 |
if (extension == null || extension.trim().isEmpty()) {
|
|
|
254 |
return responseSender.badRequest(
|
|
|
255 |
"No VBC " + vbcTelephonyService.fromType() + " is set for your account.");
|
|
|
256 |
}
|
|
|
257 |
|
|
|
258 |
try {
|
|
|
259 |
String callId = vbcDialerProvider.placeCall(me, extension, lead.getLeadMobile());
|
|
|
260 |
|
|
|
261 |
LeadCall call = new LeadCall();
|
|
|
262 |
call.setLeadId(leadId);
|
|
|
263 |
call.setAuthId(me.getId());
|
|
|
264 |
call.setProvider(LeadCallProvider.VONAGE);
|
|
|
265 |
call.setProviderCallUuid(callId);
|
|
|
266 |
call.setDirection("OUTBOUND");
|
|
|
267 |
call.setFromNumber(extension.trim());
|
|
|
268 |
call.setToNumber(vbcTelephonyService.toE164(lead.getLeadMobile()));
|
|
|
269 |
call.setStatus(LeadCallStatus.INITIATED);
|
|
|
270 |
call.setStartedAt(LocalDateTime.now());
|
|
|
271 |
call.setCreatedTimestamp(LocalDateTime.now());
|
|
|
272 |
leadCallRepository.persist(call);
|
|
|
273 |
|
|
|
274 |
Map<String, Object> out = new HashMap<>();
|
|
|
275 |
out.put("ok", true);
|
|
|
276 |
out.put("leadCallId", call.getId());
|
|
|
277 |
out.put("extension", extension.trim());
|
|
|
278 |
LOGGER.info("VBC click2dial for lead {} by auth {} -> call row {} (vendor {})",
|
|
|
279 |
leadId, me.getId(), call.getId(), callId);
|
|
|
280 |
return responseSender.ok(out);
|
|
|
281 |
} catch (Exception e) {
|
|
|
282 |
LOGGER.error("Could not place a VBC call for lead {}", leadId, e);
|
|
|
283 |
return responseSender.badRequest(e.getMessage() == null
|
|
|
284 |
? "Could not place the call." : e.getMessage());
|
|
|
285 |
}
|
|
|
286 |
}
|
|
|
287 |
|
|
|
288 |
/**
|
|
|
289 |
* Poll one call's live state. Needed because click2dial has no event webhook — the browser has
|
|
|
290 |
* to ask. Also advances the stored row so the trail and reports see the outcome.
|
|
|
291 |
*/
|
|
|
292 |
@RequestMapping(value = "/lms/dialer/state", method = RequestMethod.GET)
|
|
|
293 |
@ResponseBody
|
|
|
294 |
public ResponseEntity<?> state(HttpServletRequest request,
|
|
|
295 |
@RequestParam(name = "leadCallId") int leadCallId) {
|
|
|
296 |
LeadCall call = leadCallRepository.selectById(leadCallId);
|
|
|
297 |
if (call == null) {
|
|
|
298 |
return responseSender.notFound("Call not found");
|
|
|
299 |
}
|
|
|
300 |
if (!ownsCall(request, call)) {
|
|
|
301 |
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
|
|
302 |
}
|
|
|
303 |
Map<String, Object> out = new HashMap<>();
|
|
|
304 |
if (vbcTelephonyService != null && call.getProviderCallUuid() != null
|
|
|
305 |
&& call.getStatus() != null && !call.getStatus().isTerminal()) {
|
|
|
306 |
try {
|
|
|
307 |
com.fasterxml.jackson.databind.JsonNode live =
|
|
|
308 |
vbcTelephonyService.fetchCallState(call.getProviderCallUuid());
|
|
|
309 |
if (live != null) {
|
|
|
310 |
// Derived from the call's legs — a click2dial call has no top-level status.
|
|
|
311 |
LeadCallStatus mapped = vbcTelephonyService.deriveStatus(live);
|
|
|
312 |
if (mapped != null && mapped.advancesFrom(call.getStatus())) {
|
|
|
313 |
if (mapped == LeadCallStatus.ANSWERED && call.getAnsweredAt() == null) {
|
|
|
314 |
// Prefer VBC's own answer time: polling runs every 3s, so "now" could
|
|
|
315 |
// overstate the answer by almost that much on every call.
|
|
|
316 |
LocalDateTime answeredAt = vbcTelephonyService.retailerAnsweredAt(live);
|
|
|
317 |
call.setAnsweredAt(answeredAt == null ? LocalDateTime.now() : answeredAt);
|
|
|
318 |
}
|
|
|
319 |
call.setStatus(mapped);
|
|
|
320 |
leadCallRepository.persist(call);
|
|
|
321 |
}
|
|
|
322 |
} else if (call.getStatus() != LeadCallStatus.INITIATED) {
|
|
|
323 |
// We have seen this call live before, so VBC dropping it means it ended.
|
|
|
324 |
closeCall(call, LeadCallStatus.COMPLETED);
|
|
|
325 |
} else if (call.getStartedAt() != null
|
|
|
326 |
&& call.getStartedAt().plusSeconds(initiateGraceSeconds).isBefore(LocalDateTime.now())) {
|
|
|
327 |
// Never seen live, and too old to still be appearing: the call was accepted by
|
|
|
328 |
// VBC (it returned an id) but never reached the agent's extension.
|
|
|
329 |
//
|
|
|
330 |
// This branch is load-bearing. /calls only lists calls that are IN PROGRESS —
|
|
|
331 |
// it is not history — so a call that never connects is absent from the moment
|
|
|
332 |
// it is placed. Without a deadline it would sit at INITIATED forever and the
|
|
|
333 |
// browser would poll every 3s for the rest of the session.
|
|
|
334 |
LOGGER.warn("VBC call {} (vendor {}) never appeared as live within {}s — "
|
|
|
335 |
+ "marking failed; usually the extension has no reachable device",
|
|
|
336 |
leadCallId, call.getProviderCallUuid(), initiateGraceSeconds);
|
|
|
337 |
closeCall(call, LeadCallStatus.FAILED);
|
|
|
338 |
}
|
|
|
339 |
} catch (Exception e) {
|
|
|
340 |
LOGGER.warn("Could not refresh VBC state for call {}", leadCallId, e);
|
|
|
341 |
}
|
|
|
342 |
}
|
|
|
343 |
out.put("leadCallId", call.getId());
|
|
|
344 |
out.put("status", call.getStatus() == null ? null : call.getStatus().name());
|
|
|
345 |
out.put("terminal", call.getStatus() != null && call.getStatus().isTerminal());
|
|
|
346 |
out.put("answered", call.getAnsweredAt() != null);
|
|
|
347 |
return responseSender.ok(out);
|
|
|
348 |
}
|
|
|
349 |
|
|
|
350 |
/** Hang up a server-placed call. */
|
|
|
351 |
@RequestMapping(value = "/lms/dialer/hangup", method = RequestMethod.POST)
|
|
|
352 |
@ResponseBody
|
|
|
353 |
public ResponseEntity<?> hangup(HttpServletRequest request,
|
|
|
354 |
@RequestParam(name = "leadCallId") int leadCallId) {
|
|
|
355 |
LeadCall call = leadCallRepository.selectById(leadCallId);
|
|
|
356 |
if (call == null) {
|
|
|
357 |
return responseSender.notFound("Call not found");
|
|
|
358 |
}
|
|
|
359 |
if (!ownsCall(request, call)) {
|
|
|
360 |
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
|
|
361 |
}
|
|
|
362 |
if (vbcTelephonyService != null) {
|
|
|
363 |
vbcTelephonyService.endCall(call.getProviderCallUuid());
|
|
|
364 |
}
|
|
|
365 |
if (call.getStatus() != null && !call.getStatus().isTerminal()) {
|
|
|
366 |
closeCall(call, LeadCallStatus.COMPLETED);
|
|
|
367 |
}
|
|
|
368 |
Map<String, Object> out = new HashMap<>();
|
|
|
369 |
out.put("ok", true);
|
|
|
370 |
return responseSender.ok(out);
|
|
|
371 |
}
|
|
|
372 |
|
|
|
373 |
private boolean withinCallingHours() {
|
|
|
374 |
int hour = LocalTime.now().getHour();
|
|
|
375 |
return hour >= callingHoursStart && hour < callingHoursEnd;
|
|
|
376 |
}
|
|
|
377 |
|
|
|
378 |
// ---- Vonage callbacks ---------------------------------------------------------------------
|
|
|
379 |
|
|
|
380 |
/**
|
|
|
381 |
* Answer webhook — returns the NCCO. Accepts GET and POST because the method is an Application
|
|
|
382 |
* setting rather than something we control per call.
|
|
|
383 |
*
|
|
|
384 |
* <p>Always returns valid NCCO JSON, never an error status: a 5xx here makes Vonage retry a call
|
|
|
385 |
* that is already ringing. An unidentifiable lead yields an empty NCCO, which hangs up cleanly.
|
|
|
386 |
*/
|
|
|
387 |
@RequestMapping(value = "/vonage/voice/answer", method = {RequestMethod.GET, RequestMethod.POST},
|
|
|
388 |
produces = MediaType.APPLICATION_JSON_VALUE)
|
|
|
389 |
@ResponseBody
|
|
|
390 |
public String answer(HttpServletRequest request,
|
|
|
391 |
@RequestBody(required = false) String rawBody) {
|
|
|
392 |
try {
|
|
|
393 |
JsonNode body = (rawBody != null && !rawBody.trim().isEmpty())
|
|
|
394 |
? objectMapper.readTree(rawBody) : null;
|
|
|
395 |
|
|
|
396 |
String conversationUuid = firstNonNull(
|
|
|
397 |
body != null ? asText(body.get("conversation_uuid")) : null,
|
|
|
398 |
request.getParameter("conversation_uuid"));
|
|
|
399 |
String callUuid = firstNonNull(
|
|
|
400 |
body != null ? asText(body.get("uuid")) : null,
|
|
|
401 |
request.getParameter("uuid"));
|
|
|
402 |
|
|
|
403 |
// custom_data is what serverCall({leadId}) put on the wire.
|
|
|
404 |
int leadId = 0;
|
|
|
405 |
if (body != null && body.get("custom_data") != null) {
|
|
|
406 |
leadId = asInt(body.get("custom_data").get("leadId"));
|
|
|
407 |
}
|
|
|
408 |
if (leadId <= 0) {
|
|
|
409 |
leadId = parseInt(request.getParameter("leadId"));
|
|
|
410 |
}
|
|
|
411 |
|
|
|
412 |
// The agent is resolved from `from_user` — the JWT `sub` Vonage authenticated, echoed back
|
|
|
413 |
// to us. Deliberately NOT taken from custom_data: this webhook is unauthenticated as far
|
|
|
414 |
// as the app is concerned, and an auth id supplied by the caller would let anyone
|
|
|
415 |
// attribute a call, and its recording, to another agent. The same lookup yields the
|
|
|
416 |
// agent's own caller ID, which is why it must be trustworthy.
|
|
|
417 |
VonageAgent agent = resolveAgent(firstNonNull(
|
|
|
418 |
body != null ? asText(body.get("from_user")) : null,
|
|
|
419 |
request.getParameter("from_user")));
|
|
|
420 |
int authId = agent != null ? agent.getAuthId() : 0;
|
|
|
421 |
String agentFromNumber = agent != null ? agent.getFromNumber() : null;
|
|
|
422 |
|
|
|
423 |
Lead lead = leadId > 0 ? leadRepository.selectById(leadId) : null;
|
|
|
424 |
if (lead == null) {
|
|
|
425 |
LOGGER.error("Vonage answer webhook could not resolve a lead (conversation {})", conversationUuid);
|
|
|
426 |
return "[]";
|
|
|
427 |
}
|
|
|
428 |
// Defence in depth: the number is taken from the lead, and DND is re-checked here rather
|
|
|
429 |
// than trusting that the pre-check ran or that nothing changed since.
|
|
|
430 |
if (leadDndRepository.selectByMobile(lead.getLeadMobile()) != null) {
|
|
|
431 |
LOGGER.warn("Refusing to connect lead {} — number is on the DND register", leadId);
|
|
|
432 |
return "[]";
|
|
|
433 |
}
|
|
|
434 |
|
|
|
435 |
return vonageVoiceService.onAnswer(leadId, authId, agentFromNumber, lead.getLeadMobile(),
|
|
|
436 |
conversationUuid, callUuid);
|
|
|
437 |
} catch (Exception e) {
|
|
|
438 |
LOGGER.error("Vonage answer webhook failed", e);
|
|
|
439 |
return "[]";
|
|
|
440 |
}
|
|
|
441 |
}
|
|
|
442 |
|
|
|
443 |
/** Event webhook — call state transitions. Always ACKs; Vonage retries anything non-2xx. */
|
|
|
444 |
@RequestMapping(value = "/vonage/voice/event", method = RequestMethod.POST)
|
|
|
445 |
@ResponseBody
|
|
|
446 |
public ResponseEntity<?> event(HttpServletRequest request,
|
|
|
447 |
@RequestHeader(name = "Authorization", required = false) String authorization,
|
|
|
448 |
@RequestBody(required = false) String rawBody) {
|
|
|
449 |
if (!verifySignature(authorization, rawBody)) {
|
|
|
450 |
LOGGER.warn("Rejected a Vonage event webhook with a bad signature");
|
|
|
451 |
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
|
|
452 |
}
|
|
|
453 |
try {
|
|
|
454 |
vonageVoiceService.onEvent(objectMapper.readTree(rawBody));
|
|
|
455 |
} catch (Exception e) {
|
|
|
456 |
LOGGER.error("Vonage event webhook failed for body {}", rawBody, e);
|
|
|
457 |
}
|
|
|
458 |
return ResponseEntity.ok().build();
|
|
|
459 |
}
|
|
|
460 |
|
|
|
461 |
/** Recording webhook — attaches the vendor handle; archival happens on its own schedule. */
|
|
|
462 |
@RequestMapping(value = "/vonage/voice/recording", method = RequestMethod.POST)
|
|
|
463 |
@ResponseBody
|
|
|
464 |
public ResponseEntity<?> recording(HttpServletRequest request,
|
|
|
465 |
@RequestHeader(name = "Authorization", required = false) String authorization,
|
|
|
466 |
@RequestBody(required = false) String rawBody) {
|
|
|
467 |
if (!verifySignature(authorization, rawBody)) {
|
|
|
468 |
LOGGER.warn("Rejected a Vonage recording webhook with a bad signature");
|
|
|
469 |
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
|
|
470 |
}
|
|
|
471 |
try {
|
|
|
472 |
vonageVoiceService.onRecording(objectMapper.readTree(rawBody));
|
|
|
473 |
} catch (Exception e) {
|
|
|
474 |
LOGGER.error("Vonage recording webhook failed for body {}", rawBody, e);
|
|
|
475 |
}
|
|
|
476 |
return ResponseEntity.ok().build();
|
|
|
477 |
}
|
|
|
478 |
|
|
|
479 |
/**
|
|
|
480 |
* Vonage signed webhooks carry an HS256 JWT in {@code Authorization: Bearer}, signed with the
|
|
|
481 |
* account signature secret, whose {@code payload_hash} claim is the SHA-256 of the request body.
|
|
|
482 |
* Verifying both the signature and the hash is what stops a replayed body.
|
|
|
483 |
*
|
|
|
484 |
* <p>Returns true when no secret is configured — deliberate, so a new environment is not bricked
|
|
|
485 |
* before the secret lands, and the same opt-in shape the Pinelabs webhook already uses.
|
|
|
486 |
*/
|
|
|
487 |
private boolean verifySignature(String authorization, String rawBody) {
|
|
|
488 |
if (webhookSignatureSecret == null || webhookSignatureSecret.trim().isEmpty()) {
|
|
|
489 |
return true;
|
|
|
490 |
}
|
|
|
491 |
if (authorization == null || !authorization.trim().toLowerCase().startsWith("bearer ")) {
|
|
|
492 |
return false;
|
|
|
493 |
}
|
|
|
494 |
try {
|
|
|
495 |
String jwt = authorization.trim().substring(7).trim();
|
|
|
496 |
String[] parts = jwt.split("\\.");
|
|
|
497 |
if (parts.length != 3) {
|
|
|
498 |
return false;
|
|
|
499 |
}
|
|
|
500 |
String signingInput = parts[0] + "." + parts[1];
|
|
|
501 |
Mac mac = Mac.getInstance("HmacSHA256");
|
|
|
502 |
mac.init(new SecretKeySpec(webhookSignatureSecret.trim().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
|
503 |
byte[] expected = mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8));
|
|
|
504 |
byte[] actual = Base64.getUrlDecoder().decode(parts[2]);
|
|
|
505 |
if (!MessageDigest.isEqual(expected, actual)) {
|
|
|
506 |
return false;
|
|
|
507 |
}
|
|
|
508 |
|
|
|
509 |
JsonNode claims = objectMapper.readTree(Base64.getUrlDecoder().decode(parts[1]));
|
|
|
510 |
String payloadHash = asText(claims.get("payload_hash"));
|
|
|
511 |
if (payloadHash == null) {
|
|
|
512 |
// Signature alone is valid; a hash-less token cannot bind to this body.
|
|
|
513 |
return true;
|
|
|
514 |
}
|
|
|
515 |
byte[] digest = MessageDigest.getInstance("SHA-256")
|
|
|
516 |
.digest((rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8));
|
|
|
517 |
return MessageDigest.isEqual(hex(digest).getBytes(StandardCharsets.UTF_8),
|
|
|
518 |
payloadHash.toLowerCase().getBytes(StandardCharsets.UTF_8));
|
|
|
519 |
} catch (Exception e) {
|
|
|
520 |
LOGGER.warn("Could not verify a Vonage webhook signature", e);
|
|
|
521 |
return false;
|
|
|
522 |
}
|
|
|
523 |
}
|
|
|
524 |
|
|
|
525 |
private String hex(byte[] bytes) {
|
|
|
526 |
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
|
|
527 |
for (byte b : bytes) {
|
|
|
528 |
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
|
|
529 |
sb.append(Character.forDigit(b & 0xF, 16));
|
|
|
530 |
}
|
|
|
531 |
return sb.toString();
|
|
|
532 |
}
|
|
|
533 |
|
|
|
534 |
// ---- helpers ------------------------------------------------------------------------------
|
|
|
535 |
|
|
|
536 |
private String asText(JsonNode node) {
|
|
|
537 |
if (node == null || node.isNull()) {
|
|
|
538 |
return null;
|
|
|
539 |
}
|
|
|
540 |
String s = node.asText();
|
|
|
541 |
return (s == null || s.trim().isEmpty()) ? null : s.trim();
|
|
|
542 |
}
|
|
|
543 |
|
|
|
544 |
private int asInt(JsonNode node) {
|
|
|
545 |
return (node == null || node.isNull()) ? 0 : node.asInt(0);
|
|
|
546 |
}
|
|
|
547 |
|
|
|
548 |
private int parseInt(String s) {
|
|
|
549 |
try {
|
|
|
550 |
return (s == null || s.trim().isEmpty()) ? 0 : Integer.parseInt(s.trim());
|
|
|
551 |
} catch (NumberFormatException e) {
|
|
|
552 |
return 0;
|
|
|
553 |
}
|
|
|
554 |
}
|
|
|
555 |
|
|
|
556 |
private String firstNonNull(String a, String b) {
|
|
|
557 |
return a != null ? a : b;
|
|
|
558 |
}
|
|
|
559 |
|
|
|
560 |
/**
|
|
|
561 |
* Vonage user name -> the agent mapping, which carries both our auth id and that agent's own
|
|
|
562 |
* caller ID. Returns null when unmapped, leaving the call unattributed and falling back to the
|
|
|
563 |
* default caller ID rather than attributing it to the wrong person — an orphan call is
|
|
|
564 |
* recoverable, a misattributed recording is not.
|
|
|
565 |
*/
|
|
|
566 |
private VonageAgent resolveAgent(String vonageUserName) {
|
|
|
567 |
if (vonageUserName == null || vonageUserName.trim().isEmpty()) {
|
|
|
568 |
LOGGER.warn("Vonage answer webhook carried no from_user — call will be unattributed");
|
|
|
569 |
return null;
|
|
|
570 |
}
|
|
|
571 |
VonageAgent agent = vonageAgentRepository.selectByVonageUserName(vonageUserName);
|
|
|
572 |
if (agent == null) {
|
|
|
573 |
LOGGER.warn("No vonage_agent mapping for Vonage user '{}'", vonageUserName);
|
|
|
574 |
}
|
|
|
575 |
return agent;
|
|
|
576 |
}
|
|
|
577 |
|
|
|
578 |
/**
|
|
|
579 |
* Put a call into a terminal state: status, end time and duration together.
|
|
|
580 |
*
|
|
|
581 |
* <p>Duration is measured from {@code answered_at}, not {@code started_at} — it is meant to be
|
|
|
582 |
* how long the two people spoke, and a click2dial call spends several seconds ringing the
|
|
|
583 |
* agent's own device before the retailer is even dialled. A call that was never answered has a
|
|
|
584 |
* duration of zero rather than null, so reporting can distinguish "no conversation" from "not
|
|
|
585 |
* recorded yet".
|
|
|
586 |
*
|
|
|
587 |
* <p>Exists because the three places a call can end were each stamping status and end time by
|
|
|
588 |
* hand and none of them set duration at all — {@code setDurationSeconds} was only ever called on
|
|
|
589 |
* the WebRTC webhook path, so every click2dial call had a null duration.
|
|
|
590 |
*/
|
|
|
591 |
private void closeCall(LeadCall call, LeadCallStatus status) {
|
|
|
592 |
LocalDateTime endedAt = LocalDateTime.now();
|
|
|
593 |
call.setStatus(status);
|
|
|
594 |
call.setEndedAt(endedAt);
|
|
|
595 |
long seconds = call.getAnsweredAt() == null ? 0L
|
|
|
596 |
: java.time.Duration.between(call.getAnsweredAt(), endedAt).getSeconds();
|
|
|
597 |
call.setDurationSeconds((int) Math.max(0L, seconds));
|
|
|
598 |
leadCallRepository.persist(call);
|
|
|
599 |
}
|
|
|
600 |
|
|
|
601 |
/**
|
|
|
602 |
* Whether the caller placed this call.
|
|
|
603 |
*
|
|
|
604 |
* <p>{@code leadCallId} arrives from the browser, so without this any signed-in user could poll
|
|
|
605 |
* another agent's live call or hang it up mid-conversation by guessing a sequential id. Deliberately
|
|
|
606 |
* an exact owner check rather than a role check: a supervisor has no reason to drive someone
|
|
|
607 |
* else's call from this endpoint, and reporting reads the call rows directly.
|
|
|
608 |
*/
|
|
|
609 |
private boolean ownsCall(HttpServletRequest request, LeadCall call) {
|
|
|
610 |
AuthUser me = currentUser(request);
|
|
|
611 |
if (me == null || call.getAuthId() != me.getId()) {
|
|
|
612 |
LOGGER.warn("Auth {} tried to act on call {} owned by auth {}",
|
|
|
613 |
me == null ? null : me.getId(), call.getId(), call.getAuthId());
|
|
|
614 |
return false;
|
|
|
615 |
}
|
|
|
616 |
return true;
|
|
|
617 |
}
|
|
|
618 |
|
|
|
619 |
private AuthUser currentUser(HttpServletRequest request) {
|
|
|
620 |
try {
|
|
|
621 |
LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
|
|
|
622 |
if (loginDetails != null && loginDetails.getEmailId() != null) {
|
|
|
623 |
return authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
|
|
|
624 |
}
|
|
|
625 |
} catch (Exception e) {
|
|
|
626 |
LOGGER.warn("Could not resolve the user for a dialer request", e);
|
|
|
627 |
}
|
|
|
628 |
return null;
|
|
|
629 |
}
|
|
|
630 |
}
|