mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
271 lines
8.1 KiB
Go
271 lines
8.1 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"vocat/internal/modem"
|
|
"vocat/internal/store"
|
|
"vocat/internal/vowifi"
|
|
)
|
|
|
|
const maxCallDuration = 10 * time.Minute
|
|
|
|
func (s *Server) handleCalls(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool {
|
|
if !requireMethod(w, r, http.MethodGet) {
|
|
return true
|
|
}
|
|
transport := s.callTransport(config.ID)
|
|
if transport == "vowifi" {
|
|
controller, ok := s.vowifi.(VoWiFiCallController)
|
|
if !ok {
|
|
writeError(w, http.StatusNotImplemented, "vowifi_voice_unavailable", "the active VoWiFi IMS session does not expose voice-call signalling")
|
|
return true
|
|
}
|
|
calls, err := controller.Calls(config.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "vowifi_call_failed", err.Error())
|
|
return true
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
|
"device_id": config.ID, "transport": transport, "calls": calls,
|
|
}})
|
|
return true
|
|
}
|
|
response, err := s.devices.ExecuteAT(r.Context(), physicalID, "AT+CLCC")
|
|
if err != nil {
|
|
s.writeDeviceError(w, err)
|
|
return true
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"data": map[string]any{
|
|
"device_id": config.ID,
|
|
"transport": transport,
|
|
"calls": parseCLCC(response),
|
|
"raw": response.Text(),
|
|
},
|
|
})
|
|
return true
|
|
}
|
|
|
|
func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config store.Device, physicalID, action string) bool {
|
|
if !requireMethod(w, r, http.MethodPost) {
|
|
return true
|
|
}
|
|
command := ""
|
|
duration := time.Duration(0)
|
|
number := ""
|
|
callID := ""
|
|
switch action {
|
|
case "dial":
|
|
var request struct {
|
|
Number string `json:"number"`
|
|
DurationSeconds int `json:"duration_seconds"`
|
|
}
|
|
if err := s.decodeJSON(w, r, &request); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return true
|
|
}
|
|
number = strings.TrimSpace(request.Number)
|
|
if !validDialNumber(number) {
|
|
writeError(w, http.StatusBadRequest, "invalid_number", "phone number is invalid")
|
|
return true
|
|
}
|
|
duration = time.Duration(request.DurationSeconds) * time.Second
|
|
if duration < 0 || duration > maxCallDuration {
|
|
writeError(w, http.StatusBadRequest, "invalid_duration", "duration_seconds must be 0 (no automatic hang-up) or between 1 and 600")
|
|
return true
|
|
}
|
|
command = "ATD" + number + ";"
|
|
case "answer":
|
|
var request struct {
|
|
CallID string `json:"call_id"`
|
|
}
|
|
if err := s.decodeJSON(w, r, &request); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return true
|
|
}
|
|
callID = strings.TrimSpace(request.CallID)
|
|
command = "ATA"
|
|
case "hangup":
|
|
var request struct {
|
|
CallID string `json:"call_id"`
|
|
}
|
|
if err := s.decodeJSON(w, r, &request); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return true
|
|
}
|
|
callID = strings.TrimSpace(request.CallID)
|
|
command = "ATH"
|
|
default:
|
|
writeError(w, http.StatusNotFound, "not_found", "call action not found")
|
|
return true
|
|
}
|
|
|
|
transport := s.callTransport(config.ID)
|
|
if transport == "vowifi" {
|
|
controller, ok := s.vowifi.(VoWiFiCallController)
|
|
if !ok {
|
|
writeError(w, http.StatusNotImplemented, "vowifi_voice_unavailable", "the active VoWiFi IMS session does not expose voice-call signalling")
|
|
return true
|
|
}
|
|
var result any
|
|
var err error
|
|
switch action {
|
|
case "dial":
|
|
result, err = controller.DialCall(r.Context(), config.ID, number)
|
|
case "answer":
|
|
callID, err = resolveVoWiFiCallID(controller, config.ID, callID, "ringing")
|
|
if err == nil {
|
|
result, err = controller.AnswerCall(r.Context(), config.ID, callID)
|
|
}
|
|
case "hangup":
|
|
callID, err = resolveVoWiFiCallID(controller, config.ID, callID, "")
|
|
if err == nil {
|
|
err = controller.HangupCall(r.Context(), config.ID, callID)
|
|
}
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
|
|
return true
|
|
}
|
|
if action == "dial" {
|
|
if call, ok := result.(vowifi.Call); ok {
|
|
callID = call.ID
|
|
}
|
|
if duration > 0 {
|
|
go s.hangupVoWiFiAfter(config.ID, callID, duration)
|
|
}
|
|
}
|
|
s.recordAudit(r.Context(), "admin", "call."+action, "device", config.ID, "success", transport)
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{
|
|
"accepted": true, "action": action, "number": number, "call_id": callID,
|
|
"duration_seconds": int(duration / time.Second), "transport": transport, "call": result,
|
|
}})
|
|
return true
|
|
}
|
|
operationContext, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
response, err := s.devices.ExecuteAT(operationContext, physicalID, command)
|
|
cancel()
|
|
if err != nil {
|
|
s.writeDeviceError(w, err)
|
|
return true
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
|
|
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
|
|
return true
|
|
}
|
|
if action == "dial" {
|
|
if duration > 0 {
|
|
go s.hangupAfter(config.ID, physicalID, duration)
|
|
}
|
|
}
|
|
s.recordAudit(r.Context(), "admin", "call."+action, "device", config.ID, "success", transport)
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"data": map[string]any{
|
|
"accepted": true, "action": action, "number": number,
|
|
"duration_seconds": int(duration / time.Second), "transport": transport,
|
|
},
|
|
})
|
|
return true
|
|
}
|
|
|
|
func resolveVoWiFiCallID(controller VoWiFiCallController, deviceID, id, requiredState string) (string, error) {
|
|
if id != "" {
|
|
return id, nil
|
|
}
|
|
calls, err := controller.Calls(deviceID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, call := range calls {
|
|
if call.State != "ended" && call.State != "failed" && (requiredState == "" || call.State == requiredState) {
|
|
return call.ID, nil
|
|
}
|
|
}
|
|
return "", errors.New("no matching active call")
|
|
}
|
|
|
|
func (s *Server) hangupVoWiFiAfter(deviceID, callID string, duration time.Duration) {
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
<-timer.C
|
|
controller, ok := s.vowifi.(VoWiFiCallController)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := controller.HangupCall(ctx, deviceID, callID); err != nil {
|
|
s.logger.Warn("automatic VoWiFi call hangup failed", "device_id", deviceID, "call_id", callID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) callTransport(deviceID string) string {
|
|
if s.vowifi != nil {
|
|
// Enabled is only the desired card policy. Calls can use IMS only after
|
|
// registration has actually completed; otherwise keep using the modem's
|
|
// circuit-switched call path instead of routing into an unavailable IMS
|
|
// session.
|
|
if state, err := s.vowifi.State(deviceID); err == nil && state.IMSReady {
|
|
return "vowifi"
|
|
}
|
|
}
|
|
return "cellular"
|
|
}
|
|
|
|
func (s *Server) hangupAfter(deviceID, physicalID string, duration time.Duration) {
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
<-timer.C
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if _, err := s.devices.ExecuteAT(ctx, physicalID, "ATH"); err != nil {
|
|
s.logger.Warn("automatic call hangup failed", "device_id", deviceID, "error", err)
|
|
}
|
|
}
|
|
|
|
func validDialNumber(value string) bool {
|
|
if len(value) < 2 || len(value) > 32 {
|
|
return false
|
|
}
|
|
for index, character := range value {
|
|
if character >= '0' && character <= '9' || (index == 0 && character == '+') || character == '*' || character == '#' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseCLCC(response modem.Response) []map[string]any {
|
|
result := make([]map[string]any, 0)
|
|
for _, line := range response.Lines {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(strings.ToUpper(line), "+CLCC:") {
|
|
continue
|
|
}
|
|
fields := strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "+CLCC:")), ",")
|
|
if len(fields) < 5 {
|
|
continue
|
|
}
|
|
integer := func(index int) int {
|
|
value, _ := strconv.Atoi(strings.TrimSpace(fields[index]))
|
|
return value
|
|
}
|
|
call := map[string]any{
|
|
"index": integer(0), "direction": integer(1), "state": integer(2),
|
|
"mode": integer(3), "multiparty": integer(4), "raw": line,
|
|
}
|
|
if len(fields) > 5 {
|
|
call["number"] = strings.Trim(strings.TrimSpace(fields[5]), `"`)
|
|
}
|
|
result = append(result, call)
|
|
}
|
|
return result
|
|
}
|