Files
VoCat/internal/server/sms_notifications.go
T

448 lines
14 KiB
Go

package server
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
"vocat/internal/store"
)
const smsNotificationPollInterval = 2 * time.Second
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
type smsNotification struct {
DeviceID string
DeviceName string
DeviceLabel string
Number string
Time time.Time
Content string
}
func (value smsNotification) Text() string {
return strings.Join([]string{
"收到新短信",
"设备 " + value.DeviceLabel,
"号码 " + value.Number,
"时间 " + value.Time.Local().Format("2006-01-02 15:04:05"),
"内容 " + value.Content,
}, "\n")
}
func (value smsNotification) DetailText() string {
lines := strings.Split(value.Text(), "\n")
return strings.Join(lines[1:], "\n")
}
// StartSMSNotificationDispatchers delivers future inbound messages to the
// notification-only providers. Each provider owns its cursor so a failing
// webhook, SMTP server, or push service cannot block the other providers.
func (s *Server) StartSMSNotificationDispatchers(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
for _, channel := range smsOnlyNotificationChannels {
channel := channel
go s.runSMSNotificationChannel(ctx, channel)
}
}
func (s *Server) runSMSNotificationChannel(ctx context.Context, channel string) {
var cursor int64
cursorInitialized := false
lastError := ""
lastErrorAt := time.Time{}
for ctx.Err() == nil {
if !cursorInitialized {
latest, err := s.store.LatestSMSMessageID(ctx)
if err != nil {
if err.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
s.logSMSNotificationError(channel, err)
lastError, lastErrorAt = err.Error(), time.Now()
}
if !waitTelegram(ctx, smsNotificationPollInterval) {
return
}
continue
}
cursor, cursorInitialized = latest, true
lastError = ""
}
config, enabled, configErr := s.smsNotificationConfig(ctx, channel)
if configErr != nil {
if configErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
s.logSMSNotificationError(channel, configErr)
lastError, lastErrorAt = configErr.Error(), time.Now()
}
} else if !enabled {
if newest, latestErr := s.store.LatestSMSMessageID(ctx); latestErr == nil {
cursor = newest
}
lastError = ""
} else {
messages, listErr := s.store.ListInboundSMSAfterID(ctx, cursor, 100)
if listErr != nil {
if listErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
s.logSMSNotificationError(channel, listErr)
lastError, lastErrorAt = listErr.Error(), time.Now()
}
} else {
for _, message := range messages {
notification := s.newSMSNotification(ctx, message)
if sendErr := sendSMSNotification(ctx, channel, config, notification); sendErr != nil {
if sendErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
s.logSMSNotificationError(channel, sendErr)
lastError, lastErrorAt = sendErr.Error(), time.Now()
}
break
}
cursor = message.ID
lastError = ""
}
}
}
if !waitTelegram(ctx, smsNotificationPollInterval) {
return
}
}
}
func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map[string]any, bool, error) {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var config map[string]any
if err := json.Unmarshal(setting.Config, &config); err != nil {
return nil, false, fmt.Errorf("decode %s notification config: %w", channel, err)
}
if err := validateSMSNotificationConfig(channel, config); err != nil {
return nil, false, err
}
return config, true, nil
}
func validateSMSNotificationConfig(channel string, config map[string]any) error {
switch channel {
case "bark", "email", "webhook", "wecom":
if err := validateNotificationTestConfig(channel, config); err != nil {
return err
}
case "pushplus":
if token := strings.TrimSpace(configString(config, "token")); token == "" || token == store.SecretMask {
return errors.New("pushplus.token is required")
}
default:
return fmt.Errorf("unsupported SMS notification channel %q", channel)
}
return nil
}
func (s *Server) newSMSNotification(ctx context.Context, message store.SMSMessage) smsNotification {
name := ""
deviceID := message.DeviceID
if message.ModemIMEI != "" {
if devices, err := s.store.ListDevices(ctx); err == nil {
var newest time.Time
for _, candidate := range devices {
if candidate.ModemIMEI == message.ModemIMEI &&
(newest.IsZero() || candidate.UpdatedAt.After(newest)) {
deviceID = candidate.ID
name = strings.TrimSpace(candidate.Name)
newest = candidate.UpdatedAt
}
}
}
}
if device, err := s.store.Device(ctx, deviceID); err == nil {
name = strings.TrimSpace(device.Name)
}
return smsNotification{
DeviceID: deviceID,
DeviceName: name,
DeviceLabel: firstNonEmpty(name, deviceID, "--"),
Number: firstNonEmpty(message.Peer, "--"),
Time: message.Timestamp,
Content: message.Body,
}
}
func (s *Server) logSMSNotificationError(channel string, err error) {
if err != nil && s.logger != nil {
s.logger.Warn("send inbound SMS notification", "channel", channel, "error", err)
}
}
func sendSMSNotification(ctx context.Context, channel string, config map[string]any, message smsNotification) error {
switch channel {
case "bark":
return sendBarkSMSNotification(ctx, config, message)
case "email":
return sendEmailSMSNotification(ctx, config, message)
case "pushplus":
return sendPushplusSMSNotification(ctx, config, message)
case "webhook":
return sendWebhookSMSNotification(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomSMSValues(message))
default:
return fmt.Errorf("unsupported SMS notification channel %q", channel)
}
}
func sendBarkSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error {
client, err := restrictedHTTPClient(ctx, 6*time.Second, "")
if err != nil {
return err
}
payload := map[string]any{"title": "收到新短信", "body": message.DetailText()}
for _, field := range []string{"group", "icon", "level"} {
if value := configString(config, field); value != "" {
payload[field] = value
}
}
encoded, _ := json.Marshal(payload)
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(encoded))
if err != nil {
return fmt.Errorf("create Bark notification request: %w", err)
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-sms-notification/1")
if err := performNotificationRequest(client, request, false); err != nil {
return err
}
}
return nil
}
func sendWebhookSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error {
rendered := message.Text()
if template := configString(config, "text_template"); strings.TrimSpace(template) != "" {
rendered = renderSMSWebhookTemplate(template, message)
}
payload, _ := json.Marshal(map[string]any{
"event": "sms.received",
"message": rendered,
"timestamp": message.Time.UTC().Format(time.RFC3339),
"device_id": message.DeviceID,
"device_name": message.DeviceName,
"device_label": message.DeviceLabel,
"number": message.Number,
"content": message.Content,
})
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 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-sms-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 renderSMSWebhookTemplate(template string, message smsNotification) string {
replacements := map[string]string{
"{{text}}": message.Content,
"{{content}}": message.Content,
"{{event}}": "sms.received",
"{{timestamp}}": message.Time.UTC().Format(time.RFC3339),
"{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"),
"{{number}}": message.Number,
"{{device_id}}": message.DeviceID,
"{{device_name}}": message.DeviceName,
"{{device_label}}": message.DeviceLabel,
}
for placeholder, value := range replacements {
template = strings.ReplaceAll(template, placeholder, value)
}
return template
}
func sendPushplusSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error {
destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true)
if err != nil {
return err
}
payload := map[string]any{
"token": configString(config, "token"),
"title": "收到新短信",
"content": message.DetailText(),
"template": "txt",
"timestamp": time.Now().UnixMilli(),
}
if topic := configString(config, "topic"); topic != "" {
payload["topic"] = topic
}
if channel := configString(config, "channel"); channel != "" {
payload["channel"] = channel
}
encoded, _ := json.Marshal(payload)
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, destination.String(), bytes.NewReader(encoded))
if err != nil {
return fmt.Errorf("create Pushplus notification request: %w", err)
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-sms-notification/1")
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("send Pushplus notification: %w", err)
}
defer response.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
if readErr != nil {
return fmt.Errorf("read Pushplus response: %w", readErr)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if response.StatusCode < 200 || response.StatusCode >= 300 || json.Unmarshal(body, &result) != nil || result.Code != 200 {
return fmt.Errorf("%w: Pushplus HTTP %d code %d %s", errProviderRejected, response.StatusCode, result.Code, result.Msg)
}
return nil
}
func sendEmailSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error {
host := strings.TrimSpace(configString(config, "smtp_host"))
port := configInt(config, "smtp_port")
if port == 0 {
port = 587
}
timeout := 8 * time.Second
connection, err := dialRestricted(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
if err != nil {
return fmt.Errorf("connect SMTP server: %w", err)
}
defer connection.Close()
if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
return fmt.Errorf("set SMTP deadline: %w", err)
}
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host}
useSSL, _ := config["use_ssl"].(bool)
implicitTLS := port == 465 || useSSL
if implicitTLS {
secure := tls.Client(connection, tlsConfig)
if err := secure.HandshakeContext(ctx); err != nil {
return fmt.Errorf("establish SMTP TLS: %w", err)
}
connection = secure
}
client, err := smtp.NewClient(connection, host)
if err != nil {
return fmt.Errorf("start SMTP session: %w", err)
}
defer client.Close()
if !implicitTLS {
if available, _ := client.Extension("STARTTLS"); !available {
return errors.New("SMTP server does not offer STARTTLS")
}
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("start SMTP TLS: %w", err)
}
}
username, password := configString(config, "username"), configString(config, "password")
if username != "" {
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
}
}
from, err := parseMailAddress(configString(config, "from_address"))
if err != nil {
return fmt.Errorf("parse sender address: %w", err)
}
recipients := make([]*mail.Address, 0)
for _, item := range configStrings(config, "to_addresses") {
address, err := parseMailAddress(item)
if err != nil {
return fmt.Errorf("parse recipient address: %w", err)
}
recipients = append(recipients, address)
}
if err := client.Mail(from.Address); err != nil {
return fmt.Errorf("%w: SMTP sender rejected", errProviderRejected)
}
for _, recipient := range recipients {
if err := client.Rcpt(recipient.Address); err != nil {
return fmt.Errorf("%w: SMTP recipient rejected", errProviderRejected)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
}
if err := writePlainTextMail(
writer,
from,
recipients,
"收到新短信 - "+message.DeviceLabel,
message.Text(),
); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP notification: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("%w: SMTP message not accepted", errProviderRejected)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("finish SMTP session: %w", err)
}
return nil
}