mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 14:53:42 +08:00
feat: implement incoming call monitoring and multi-channel notification support
This commit is contained in:
+37
-2
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user