feat: implement incoming call monitoring and multi-channel notification support

This commit is contained in:
MengMengCode
2026-08-20 02:06:36 +08:00
parent 9eebcc1773
commit 8accaaaabc
6 changed files with 566 additions and 2 deletions
+37 -2
View File
@@ -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 ""
}
+343
View File
@@ -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",
})
}
}
}
}
+108
View File
@@ -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)
}
}
+19
View File
@@ -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)
+47
View File
@@ -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:[email protected]"},
}
packet, err := parseSIPPacket([]byte(strings.Join([]string{
"INVITE sip:[email protected] SIP/2.0",
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-notify",
"From: <tel:+447700999888>;tag=caller-tag",
"To: <tel:+447700900123>",
"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"}}
+12
View File
@@ -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.