| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438 |
- package server
- import (
- "bytes"
- "context"
- "crypto/hmac"
- "crypto/sha256"
- "crypto/tls"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "mime"
- "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"}
- 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":
- 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 := ""
- if device, err := s.store.Device(ctx, message.DeviceID); err == nil {
- name = strings.TrimSpace(device.Name)
- }
- return smsNotification{
- DeviceID: message.DeviceID,
- DeviceName: name,
- DeviceLabel: firstNonEmpty(name, message.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)
- 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 := mail.ParseAddress(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 := mail.ParseAddress(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)
- }
- email := strings.Join([]string{
- "Date: " + time.Now().UTC().Format(time.RFC1123Z),
- "From: " + from.String(),
- "To: " + joinMailAddresses(recipients),
- "Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel),
- "MIME-Version: 1.0",
- "Content-Type: text/plain; charset=UTF-8",
- "Content-Transfer-Encoding: 8bit",
- "",
- message.Text(),
- "",
- }, "\r\n")
- if _, err := io.WriteString(writer, email); 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
- }
|