mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 14:53:42 +08:00
344 lines
10 KiB
Go
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",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|