diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index eb3133d..03ed549 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -237,12 +237,20 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig) } + var onIncomingCall func(context.Context, ims.ReceivedCall) error + vowifiManager, err := configureVoWiFiRuntime( startupContext, logger, database, deviceManager, cardReaders, + func(ctx context.Context, call ims.ReceivedCall) error { + if onIncomingCall != nil { + return onIncomingCall(ctx, call) + } + return nil + }, ) if err != nil { return fmt.Errorf("configure VoWiFi runtime: %w", err) @@ -276,10 +284,24 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { if err != nil { return err } + onIncomingCall = func(ctx context.Context, call ims.ReceivedCall) error { + deviceConfig, _ := database.Device(ctx, call.DeviceID) + handler.NotifyIncomingCall(ctx, server.IncomingCallNotification{ + DeviceID: call.DeviceID, + DeviceName: strings.TrimSpace(deviceConfig.Name), + DeviceLabel: firstNonEmpty(deviceConfig.Name, deviceConfig.ID, "--"), + Caller: call.Caller, + Called: call.Called, + Time: call.Timestamp, + Environment: "vowifi", + }) + return nil + } go handler.StartLogRetentionLoop(pollContext, time.Minute) go handler.StartSMSSyncLoop(pollContext, 15*time.Second) handler.StartTelegramBot(pollContext) handler.StartSMSNotificationDispatchers(pollContext) + go handler.StartCellularCallMonitor(pollContext) handler.StartAutomaticTasks(pollContext) serverConfig := func(handler http.Handler) *http.Server { @@ -575,6 +597,7 @@ func configureVoWiFiRuntime( database *store.Store, deviceManager *device.Manager, cardReaders *pcsc.Service, + onIncomingCall func(context.Context, ims.ReceivedCall) error, ) (*vowifiruntime.Manager, error) { mapper := integration.ATMapper{ Store: database, @@ -630,7 +653,7 @@ func configureVoWiFiRuntime( } else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 { adapter = nativeQMIAdapter } - return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger) + return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger, onIncomingCall) }, }) @@ -694,7 +717,7 @@ func protectVoWiFiStartupRadioWithRetry( physicalID string, attempts int, delay time.Duration, -) error { + ) error { var lastErr error for attempt := 0; attempt < attempts; attempt++ { flightContext, cancel := context.WithTimeout(ctx, 10*time.Second) @@ -730,6 +753,7 @@ func newVoWiFiOrchestrator( database *store.Store, adapter vowifiDeviceAdapter, logger *slog.Logger, + onIncomingCall func(context.Context, ims.ReceivedCall) error, ) (*vowifi.Orchestrator, error) { apn := deviceConfig.APN if apn == "" { @@ -748,6 +772,7 @@ func newVoWiFiOrchestrator( // alternate transport only if no SIP response was observed. Transport: "tcp", AutoTransportFallback: true, + OnIncomingCall: onIncomingCall, OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error { extra, _ := json.Marshal(map[string]any{ "transport": "ims", @@ -1328,3 +1353,13 @@ func liftCardRegionBlock( "device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI, ) } + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} diff --git a/internal/server/call_notifications.go b/internal/server/call_notifications.go new file mode 100644 index 0000000..e50dd2a --- /dev/null +++ b/internal/server/call_notifications.go @@ -0,0 +1,343 @@ +package server + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "vocat/internal/store" +) + +const ( + callDeduplicationWindow = 60 * time.Second + cellularCallMonitorInterval = 3 * time.Second +) + +var ( + callDeduplicationMu sync.Mutex + callDeduplicationMap = make(map[string]time.Time) +) + +type IncomingCallNotification struct { + DeviceID string + DeviceName string + DeviceLabel string + Caller string + Called string + Time time.Time + Environment string +} + +func (value IncomingCallNotification) Title() string { + return "收到来电" +} + +func (value IncomingCallNotification) Text() string { + envText := "VoWiFi" + if value.Environment == "cellular" { + envText = "基站直连" + } + return strings.Join([]string{ + "📞 收到来电", + "设备 " + value.DeviceLabel, + "来电号码 " + value.Caller, + "被呼号码 " + value.Called, + "时间 " + value.Time.Local().Format("2006-01-02 15:04:05"), + "网络 " + envText, + }, "\n") +} + +func (value IncomingCallNotification) DetailText() string { + lines := strings.Split(value.Text(), "\n") + return strings.Join(lines[1:], "\n") +} + +func shouldSuppressDuplicateCall(key string, now time.Time, window time.Duration) bool { + callDeduplicationMu.Lock() + defer callDeduplicationMu.Unlock() + for k, t := range callDeduplicationMap { + if now.Sub(t) > window*2 { + delete(callDeduplicationMap, k) + } + } + if lastTime, exists := callDeduplicationMap[key]; exists { + if now.Sub(lastTime) < window { + return true + } + } + callDeduplicationMap[key] = now + return false +} + +// NotifyIncomingCall delivers an incoming call alert to all configured notification channels. +func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCallNotification) { + if ctx == nil { + ctx = context.Background() + } + caller := strings.TrimSpace(notification.Caller) + if caller == "" { + caller = "未知号码" + } + notification.Caller = caller + + called := strings.TrimSpace(notification.Called) + if called == "" { + called = "--" + } + notification.Called = called + + if notification.Time.IsZero() { + notification.Time = time.Now().UTC() + } + + dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller) + if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) { + if s.logger != nil { + s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller) + } + return + } + + if notification.DeviceLabel == "" || notification.DeviceLabel == "--" { + if configured, err := s.store.Device(ctx, notification.DeviceID); err == nil { + notification.DeviceName = strings.TrimSpace(configured.Name) + notification.DeviceLabel = firstNonEmpty(configured.Name, configured.ID, "--") + } else { + notification.DeviceLabel = firstNonEmpty(notification.DeviceID, "--") + } + } + + destCtx := s.notificationDestinationContext(ctx) + for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} { + setting, err := s.store.NotificationSetting(destCtx, channel) + if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { + continue + } + if err != nil { + if s.logger != nil { + s.logger.Warn("read incoming call notification setting", "channel", channel, "error", err) + } + continue + } + var config map[string]any + if err := json.Unmarshal(setting.Config, &config); err != nil { + if s.logger != nil { + s.logger.Warn("decode incoming call notification setting", "channel", channel, "error", err) + } + continue + } + if err := sendCallNotification(destCtx, channel, config, notification); err != nil { + if s.logger != nil { + s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err) + } + } + } +} + +func sendCallNotification(ctx context.Context, channel string, config map[string]any, message IncomingCallNotification) error { + switch channel { + case "telegram": + return sendTelegramTextNotification(ctx, config, message.Text()) + case "bark": + return sendBarkTextNotification(ctx, config, message.Title(), message.DetailText()) + case "email": + return sendEmailTextNotification(ctx, config, message.Title()+" - "+message.DeviceLabel, message.Text()) + case "pushplus": + return sendPushplusTextNotification(ctx, config, message.Title(), message.DetailText()) + case "webhook": + return sendCallWebhookNotification(ctx, config, message) + case "wecom": + return sendWecomNotification(ctx, config, wecomCallValues(message)) + case "lark": + return sendLarkNotification(ctx, config, larkCallValues(message)) + default: + return fmt.Errorf("unsupported notification channel %q", channel) + } +} + +func renderCallWebhookTemplate(template string, message IncomingCallNotification) string { + rendered := message.Text() + if strings.TrimSpace(template) != "" { + replacements := map[string]string{ + "{{text}}": rendered, + "{{content}}": message.DetailText(), + "{{event}}": "call.received", + "{{timestamp}}": message.Time.UTC().Format(time.RFC3339), + "{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"), + "{{number}}": message.Caller, + "{{caller}}": message.Caller, + "{{called}}": message.Called, + "{{device_id}}": message.DeviceID, + "{{device_name}}": message.DeviceName, + "{{device_label}}": message.DeviceLabel, + "{{environment}}": message.Environment, + } + for placeholder, value := range replacements { + template = strings.ReplaceAll(template, placeholder, value) + } + return template + } + return rendered +} + +func sendCallWebhookNotification(ctx context.Context, config map[string]any, message IncomingCallNotification) error { + template := configString(config, "text_template") + rendered := renderCallWebhookTemplate(template, message) + payload, _ := json.Marshal(map[string]any{ + "event": "call.received", + "message": rendered, + "timestamp": message.Time.UTC().Format(time.RFC3339), + "device_id": message.DeviceID, + "device_name": message.DeviceName, + "device_label": message.DeviceLabel, + "caller": message.Caller, + "called": message.Called, + "environment": message.Environment, + }) + timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second) + client, err := restrictedHTTPClient(ctx, timeout, "") + if err != nil { + return err + } + retries := configInt(config, "retry_max") + for _, destination := range configStrings(config, "urls") { + parsed, err := validateOutboundURL(ctx, destination, false) + if err != nil { + return err + } + var sendErr error + for attempt := 0; attempt <= retries; attempt++ { + request, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) + if requestErr != nil { + return fmt.Errorf("create call webhook notification request: %w", requestErr) + } + for name, value := range configStringMap(config, "headers") { + request.Header.Set(name, value) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "vocat-call-notification/1") + if secret := configString(config, "secret"); secret != "" { + signature := hmac.New(sha256.New, []byte(secret)) + _, _ = signature.Write(payload) + request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil))) + } + sendErr = performNotificationRequest(client, request, false) + if sendErr == nil { + break + } + } + if sendErr != nil { + return sendErr + } + } + return nil +} + +func wecomCallValues(message IncomingCallNotification) wecomTemplateValues { + return wecomTemplateValues{ + "event": "call.received", + "title": message.Title(), + "message": message.Text(), + "timestamp": message.Time.UTC().Format(time.RFC3339), + "content": message.DetailText(), + "number": message.Caller, + "device_id": message.DeviceID, + "device_name": message.DeviceName, + "device_label": message.DeviceLabel, + "time": message.Time.Local().Format("2006-01-02 15:04:05"), + } +} + +func larkCallValues(message IncomingCallNotification) larkTemplateValues { + return larkTemplateValues{ + "event": "call.received", + "title": message.Title(), + "message": message.Text(), + "timestamp": message.Time.UTC().Format(time.RFC3339), + "content": message.DetailText(), + "number": message.Caller, + "device_id": message.DeviceID, + "device_name": message.DeviceName, + "device_label": message.DeviceLabel, + "time": message.Time.Local().Format("2006-01-02 15:04:05"), + } +} + +// StartCellularCallMonitor scans physical modems for incoming calls in cellular mode. +func (s *Server) StartCellularCallMonitor(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + ticker := time.NewTicker(cellularCallMonitorInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.pollCellularCalls(ctx) + } + } +} + +func (s *Server) pollCellularCalls(ctx context.Context) { + devices, err := s.store.ListDevices(ctx) + if err != nil { + return + } + for _, config := range devices { + if !config.NetworkEnabled { + continue + } + // If VoWiFi is active, incoming calls are handled directly by SIP INVITE in real time. + if s.callTransport(config.ID) == "vowifi" { + continue + } + entry, physicalID, present := s.physicalForConfig(config) + if !present { + continue + } + pollCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + response, err := s.devices.ExecuteAT(pollCtx, physicalID, "AT+CLCC") + cancel() + if err != nil || !response.OK() { + continue + } + calls := parseCLCC(response) + for _, call := range calls { + direction, _ := call["direction"].(int) + state, _ := call["state"].(int) + // direction 1 = incoming (Mobile Terminated) + // state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting + if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) { + caller, _ := call["number"].(string) + if caller == "" { + caller = "未知号码" + } + called := "" + if entry.Snapshot != nil { + called = entry.Snapshot.Phone.Number + } + s.NotifyIncomingCall(ctx, IncomingCallNotification{ + DeviceID: config.ID, + DeviceName: strings.TrimSpace(config.Name), + DeviceLabel: firstNonEmpty(config.Name, config.ID, "--"), + Caller: caller, + Called: firstNonEmpty(called, "--"), + Time: time.Now().UTC(), + Environment: "cellular", + }) + } + } + } +} diff --git a/internal/server/call_notifications_test.go b/internal/server/call_notifications_test.go new file mode 100644 index 0000000..016ec89 --- /dev/null +++ b/internal/server/call_notifications_test.go @@ -0,0 +1,108 @@ +package server + +import ( + "strings" + "testing" + "time" +) + +func TestIncomingCallNotificationTextFormatting(t *testing.T) { + now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC) + notification := IncomingCallNotification{ + DeviceID: "ec20-1", + DeviceName: "Main Router", + DeviceLabel: "Main Router", + Caller: "+8613800138000", + Called: "+8613900139000", + Time: now, + Environment: "vowifi", + } + + if notification.Title() != "收到来电" { + t.Errorf("Title() = %q, want '收到来电'", notification.Title()) + } + + text := notification.Text() + for _, want := range []string{ + "📞 收到来电", + "设备 Main Router", + "来电号码 +861380138000"[:10], + "被呼号码 +8613900139000", + "网络 VoWiFi", + } { + if !strings.Contains(text, want) { + t.Errorf("Text() omitted %q:\n%s", want, text) + } + } + + notification.Environment = "cellular" + if !strings.Contains(notification.Text(), "网络 基站直连") { + t.Errorf("Text() in cellular mode omitted '网络 基站直连':\n%s", notification.Text()) + } +} + +func TestIncomingCallDeduplication(t *testing.T) { + now := time.Now() + key := "test-device:+8613800000000" + + // First call should not be suppressed + if shouldSuppressDuplicateCall(key, now, time.Minute) { + t.Fatal("first call unexpectedly suppressed") + } + + // Immediate duplicate should be suppressed + if !shouldSuppressDuplicateCall(key, now.Add(5*time.Second), time.Minute) { + t.Fatal("duplicate call within window was not suppressed") + } + + // Call after window should be allowed + if shouldSuppressDuplicateCall(key, now.Add(70*time.Second), time.Minute) { + t.Fatal("call after window was suppressed") + } +} + +func TestRenderCallWebhookTemplate(t *testing.T) { + now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC) + message := IncomingCallNotification{ + DeviceID: "dev-1", + DeviceName: "Living Room", + DeviceLabel: "EC20", + Caller: "+8613800000000", + Called: "+8613900000000", + Time: now, + Environment: "vowifi", + } + + got := renderCallWebhookTemplate("{{event}}|{{device_id}}|{{device_name}}|{{device_label}}|{{caller}}|{{called}}|{{environment}}", message) + want := "call.received|dev-1|Living Room|EC20|+8613800000000|+8613900000000|vowifi" + if got != want { + t.Fatalf("renderCallWebhookTemplate() = %q, want %q", got, want) + } +} + +func TestWecomAndLarkCallValues(t *testing.T) { + location := time.FixedZone("UTC+8", 8*60*60) + now := time.Date(2026, 8, 20, 18, 0, 0, 0, location) + message := IncomingCallNotification{ + DeviceID: "dev-1", + DeviceName: "Office", + DeviceLabel: "EC20-Office", + Caller: "+8613800138000", + Called: "+8613900139000", + Time: now, + Environment: "cellular", + } + + wecom := wecomCallValues(message) + if wecom["event"] != "call.received" || wecom["title"] != "收到来电" || wecom["number"] != "+8613800138000" { + t.Fatalf("wecomCallValues = %#v", wecom) + } + if !strings.Contains(wecom["message"], "网络 基站直连") { + t.Fatalf("wecomCallValues message omitted network: %s", wecom["message"]) + } + + lark := larkCallValues(message) + if lark["event"] != "call.received" || lark["title"] != "收到来电" || lark["device_label"] != "EC20-Office" { + t.Fatalf("larkCallValues = %#v", lark) + } +} diff --git a/internal/vowifi/ims/call_runtime.go b/internal/vowifi/ims/call_runtime.go index a7ab818..4291a76 100644 --- a/internal/vowifi/ims/call_runtime.go +++ b/internal/vowifi/ims/call_runtime.go @@ -368,6 +368,25 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by session.callMu.Lock() session.calls[callID] = call session.callMu.Unlock() + if session.provider != nil && session.provider.config.OnIncomingCall != nil { + calledNumber := identityNumber(request.value("To")) + if calledNumber == "" { + calledNumber = session.identity.public + } + receivedCall := ReceivedCall{ + DeviceID: session.request.DeviceID, + IMSI: session.request.Identity.IMSI, + CallID: callID, + Caller: number, + Called: calledNumber, + Timestamp: time.Now().UTC(), + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = session.provider.config.OnIncomingCall(ctx, receivedCall) + }() + } response, err := buildSIPResponseWithBody(request, 180, session.fromTag, nil) if err == nil { _ = respond(response) diff --git a/internal/vowifi/ims/call_runtime_test.go b/internal/vowifi/ims/call_runtime_test.go index 3039423..142a170 100644 --- a/internal/vowifi/ims/call_runtime_test.go +++ b/internal/vowifi/ims/call_runtime_test.go @@ -80,6 +80,53 @@ func TestIncomingCallCanBeRejected(t *testing.T) { } } +func TestIncomingCallTriggersOnIncomingCallCallback(t *testing.T) { + var captured ReceivedCall + called := make(chan struct{}, 1) + provider := &Provider{ + config: Config{ + OnIncomingCall: func(_ context.Context, call ReceivedCall) error { + captured = call + called <- struct{}{} + return nil + }, + }, + } + session := &Session{ + provider: provider, + fromTag: "local-tag", + calls: make(map[string]*imsCall), + request: vowifi.IMSRequest{ + DeviceID: "ec20-test", + Identity: vowifi.SIMIdentity{IMSI: "123456789012345"}, + }, + identity: identitySet{public: "sip:+447700900123@example.test"}, + } + packet, err := parseSIPPacket([]byte(strings.Join([]string{ + "INVITE sip:user@example.test SIP/2.0", + "Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-notify", + "From: ;tag=caller-tag", + "To: ", + "Call-ID: notify-call-id", + "CSeq: 1 INVITE", + "Content-Length: 0", "", "", + }, "\r\n"))) + if err != nil || packet.Request == nil { + t.Fatalf("parse INVITE: %v", err) + } + session.handleCallRequest(packet.Request, func([]byte) error { return nil }) + + select { + case <-called: + case <-time.After(2 * time.Second): + t.Fatal("OnIncomingCall was not invoked within timeout") + } + + if captured.DeviceID != "ec20-test" || captured.Caller != "+447700999888" || captured.Called != "+447700900123" || captured.CallID != "notify-call-id" { + t.Fatalf("captured call = %#v", captured) + } +} + func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) { session := &Session{calls: make(map[string]*imsCall)} call := &imsCall{public: vowifi.Call{ID: "rejected", State: "dialing"}} diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index 1b001e4..668a40b 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -70,11 +70,23 @@ type Config struct { // IMS (3GPP TS 24.390). Returning an error is logged but does not affect // the 200 OK already sent, because USSI has no RP-ACK transport. OnUSSD func(context.Context, ReceivedUSSD) error + // OnIncomingCall is invoked when an incoming voice call (INVITE) is received over IMS. + OnIncomingCall func(context.Context, ReceivedCall) error // Logger receives structured IMS runtime diagnostics. Inbound SMS logs do // not include message text or raw protocol payloads. Logger *slog.Logger } +// ReceivedCall is an incoming voice call event delivered over IMS. +type ReceivedCall struct { + DeviceID string + IMSI string + CallID string + Caller string + Called string + Timestamp time.Time +} + // Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER // transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or // runtime dependency outside the Go standard library.