diff --git a/internal/server/call_notifications.go b/internal/server/call_notifications.go index 29d9b45..f6acd6a 100644 --- a/internal/server/call_notifications.go +++ b/internal/server/call_notifications.go @@ -325,11 +325,7 @@ func (s *Server) pollCellularCalls(ctx context.Context) { } 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) { + if isIncomingVoiceCLCC(call) { caller, _ := call["number"].(string) if caller == "" { caller = "未知号码" @@ -351,3 +347,13 @@ func (s *Server) pollCellularCalls(ctx context.Context) { } } } + +func isIncomingVoiceCLCC(call map[string]any) bool { + direction, _ := call["direction"].(int) + state, _ := call["state"].(int) + mode, _ := call["mode"].(int) + // direction 1 = incoming (Mobile Terminated) + // mode 0 = voice; some modems also expose packet-data sessions as CLCC mode 1 + // state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting + return direction == 1 && mode == 0 && (state == 4 || state == 5 || state == 0 || state == 3) +} diff --git a/internal/server/call_notifications_test.go b/internal/server/call_notifications_test.go index 016ec89..6d7838f 100644 --- a/internal/server/call_notifications_test.go +++ b/internal/server/call_notifications_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "time" + + "vocat/internal/modem" ) func TestIncomingCallNotificationTextFormatting(t *testing.T) { @@ -61,6 +63,56 @@ func TestIncomingCallDeduplication(t *testing.T) { } } +func TestIncomingVoiceCLCCIgnoresDataSessions(t *testing.T) { + tests := []struct { + name string + call map[string]any + want bool + }{ + { + name: "incoming voice ringing", + call: map[string]any{"direction": 1, "state": 4, "mode": 0}, + want: true, + }, + { + name: "incoming voice active", + call: map[string]any{"direction": 1, "state": 0, "mode": 0}, + want: true, + }, + { + name: "incoming packet data active", + call: map[string]any{"direction": 1, "state": 0, "mode": 1}, + want: false, + }, + { + name: "outgoing voice alerting", + call: map[string]any{"direction": 0, "state": 3, "mode": 0}, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isIncomingVoiceCLCC(test.call); got != test.want { + t.Fatalf("isIncomingVoiceCLCC() = %v, want %v", got, test.want) + } + }) + } + + // EC20/EC25 firmware may expose an active packet-data session in CLCC. + // It must not be treated as an incoming voice call. + dataCalls := parseCLCC(modem.Response{ + Lines: []string{`+CLCC: 1,1,0,1,0,"",128`}, + Final: "OK", + }) + if len(dataCalls) != 1 { + t.Fatalf("parseCLCC() returned %d data calls, want 1", len(dataCalls)) + } + if isIncomingVoiceCLCC(dataCalls[0]) { + t.Fatal("active packet-data CLCC record was treated as an incoming voice call") + } +} + func TestRenderCallWebhookTemplate(t *testing.T) { now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC) message := IncomingCallNotification{