Files
VoCat/internal/server/call_notifications.go
T

344 lines
10 KiB
Go

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",
})
}
}
}
}