sms_notifications.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. package server
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "crypto/tls"
  8. "encoding/hex"
  9. "encoding/json"
  10. "errors"
  11. "fmt"
  12. "io"
  13. "mime"
  14. "net"
  15. "net/http"
  16. "net/mail"
  17. "net/smtp"
  18. "strconv"
  19. "strings"
  20. "time"
  21. "vocat/internal/store"
  22. )
  23. const smsNotificationPollInterval = 2 * time.Second
  24. var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook"}
  25. type smsNotification struct {
  26. DeviceID string
  27. DeviceName string
  28. DeviceLabel string
  29. Number string
  30. Time time.Time
  31. Content string
  32. }
  33. func (value smsNotification) Text() string {
  34. return strings.Join([]string{
  35. "收到新短信",
  36. "设备 " + value.DeviceLabel,
  37. "号码 " + value.Number,
  38. "时间 " + value.Time.Local().Format("2006-01-02 15:04:05"),
  39. "内容 " + value.Content,
  40. }, "\n")
  41. }
  42. func (value smsNotification) DetailText() string {
  43. lines := strings.Split(value.Text(), "\n")
  44. return strings.Join(lines[1:], "\n")
  45. }
  46. // StartSMSNotificationDispatchers delivers future inbound messages to the
  47. // notification-only providers. Each provider owns its cursor so a failing
  48. // webhook, SMTP server, or push service cannot block the other providers.
  49. func (s *Server) StartSMSNotificationDispatchers(ctx context.Context) {
  50. if ctx == nil {
  51. ctx = context.Background()
  52. }
  53. for _, channel := range smsOnlyNotificationChannels {
  54. channel := channel
  55. go s.runSMSNotificationChannel(ctx, channel)
  56. }
  57. }
  58. func (s *Server) runSMSNotificationChannel(ctx context.Context, channel string) {
  59. var cursor int64
  60. cursorInitialized := false
  61. lastError := ""
  62. lastErrorAt := time.Time{}
  63. for ctx.Err() == nil {
  64. if !cursorInitialized {
  65. latest, err := s.store.LatestSMSMessageID(ctx)
  66. if err != nil {
  67. if err.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
  68. s.logSMSNotificationError(channel, err)
  69. lastError, lastErrorAt = err.Error(), time.Now()
  70. }
  71. if !waitTelegram(ctx, smsNotificationPollInterval) {
  72. return
  73. }
  74. continue
  75. }
  76. cursor, cursorInitialized = latest, true
  77. lastError = ""
  78. }
  79. config, enabled, configErr := s.smsNotificationConfig(ctx, channel)
  80. if configErr != nil {
  81. if configErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
  82. s.logSMSNotificationError(channel, configErr)
  83. lastError, lastErrorAt = configErr.Error(), time.Now()
  84. }
  85. } else if !enabled {
  86. if newest, latestErr := s.store.LatestSMSMessageID(ctx); latestErr == nil {
  87. cursor = newest
  88. }
  89. lastError = ""
  90. } else {
  91. messages, listErr := s.store.ListInboundSMSAfterID(ctx, cursor, 100)
  92. if listErr != nil {
  93. if listErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
  94. s.logSMSNotificationError(channel, listErr)
  95. lastError, lastErrorAt = listErr.Error(), time.Now()
  96. }
  97. } else {
  98. for _, message := range messages {
  99. notification := s.newSMSNotification(ctx, message)
  100. if sendErr := sendSMSNotification(ctx, channel, config, notification); sendErr != nil {
  101. if sendErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
  102. s.logSMSNotificationError(channel, sendErr)
  103. lastError, lastErrorAt = sendErr.Error(), time.Now()
  104. }
  105. break
  106. }
  107. cursor = message.ID
  108. lastError = ""
  109. }
  110. }
  111. }
  112. if !waitTelegram(ctx, smsNotificationPollInterval) {
  113. return
  114. }
  115. }
  116. }
  117. func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map[string]any, bool, error) {
  118. setting, err := s.store.NotificationSetting(ctx, channel)
  119. if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
  120. return nil, false, nil
  121. }
  122. if err != nil {
  123. return nil, false, err
  124. }
  125. var config map[string]any
  126. if err := json.Unmarshal(setting.Config, &config); err != nil {
  127. return nil, false, fmt.Errorf("decode %s notification config: %w", channel, err)
  128. }
  129. if err := validateSMSNotificationConfig(channel, config); err != nil {
  130. return nil, false, err
  131. }
  132. return config, true, nil
  133. }
  134. func validateSMSNotificationConfig(channel string, config map[string]any) error {
  135. switch channel {
  136. case "bark", "email", "webhook":
  137. if err := validateNotificationTestConfig(channel, config); err != nil {
  138. return err
  139. }
  140. case "pushplus":
  141. if token := strings.TrimSpace(configString(config, "token")); token == "" || token == store.SecretMask {
  142. return errors.New("pushplus.token is required")
  143. }
  144. default:
  145. return fmt.Errorf("unsupported SMS notification channel %q", channel)
  146. }
  147. return nil
  148. }
  149. func (s *Server) newSMSNotification(ctx context.Context, message store.SMSMessage) smsNotification {
  150. name := ""
  151. if device, err := s.store.Device(ctx, message.DeviceID); err == nil {
  152. name = strings.TrimSpace(device.Name)
  153. }
  154. return smsNotification{
  155. DeviceID: message.DeviceID,
  156. DeviceName: name,
  157. DeviceLabel: firstNonEmpty(name, message.DeviceID, "--"),
  158. Number: firstNonEmpty(message.Peer, "--"),
  159. Time: message.Timestamp,
  160. Content: message.Body,
  161. }
  162. }
  163. func (s *Server) logSMSNotificationError(channel string, err error) {
  164. if err != nil && s.logger != nil {
  165. s.logger.Warn("send inbound SMS notification", "channel", channel, "error", err)
  166. }
  167. }
  168. func sendSMSNotification(ctx context.Context, channel string, config map[string]any, message smsNotification) error {
  169. switch channel {
  170. case "bark":
  171. return sendBarkSMSNotification(ctx, config, message)
  172. case "email":
  173. return sendEmailSMSNotification(ctx, config, message)
  174. case "pushplus":
  175. return sendPushplusSMSNotification(ctx, config, message)
  176. case "webhook":
  177. return sendWebhookSMSNotification(ctx, config, message)
  178. default:
  179. return fmt.Errorf("unsupported SMS notification channel %q", channel)
  180. }
  181. }
  182. func sendBarkSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error {
  183. client, err := restrictedHTTPClient(ctx, 6*time.Second, "")
  184. if err != nil {
  185. return err
  186. }
  187. payload := map[string]any{"title": "收到新短信", "body": message.DetailText()}
  188. for _, field := range []string{"group", "icon", "level"} {
  189. if value := configString(config, field); value != "" {
  190. payload[field] = value
  191. }
  192. }
  193. encoded, _ := json.Marshal(payload)
  194. for _, destination := range configStrings(config, "urls") {
  195. parsed, err := validateOutboundURL(ctx, destination, false)
  196. if err != nil {
  197. return err
  198. }
  199. request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(encoded))
  200. if err != nil {
  201. return fmt.Errorf("create Bark notification request: %w", err)
  202. }
  203. request.Header.Set("Content-Type", "application/json; charset=utf-8")
  204. request.Header.Set("User-Agent", "vocat-sms-notification/1")
  205. if err := performNotificationRequest(client, request, false); err != nil {
  206. return err
  207. }
  208. }
  209. return nil
  210. }
  211. func sendWebhookSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error {
  212. rendered := message.Text()
  213. if template := configString(config, "text_template"); strings.TrimSpace(template) != "" {
  214. rendered = renderSMSWebhookTemplate(template, message)
  215. }
  216. payload, _ := json.Marshal(map[string]any{
  217. "event": "sms.received",
  218. "message": rendered,
  219. "timestamp": message.Time.UTC().Format(time.RFC3339),
  220. "device_id": message.DeviceID,
  221. "device_name": message.DeviceName,
  222. "device_label": message.DeviceLabel,
  223. "number": message.Number,
  224. "content": message.Content,
  225. })
  226. timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second)
  227. client, err := restrictedHTTPClient(ctx, timeout, "")
  228. if err != nil {
  229. return err
  230. }
  231. retries := configInt(config, "retry_max")
  232. for _, destination := range configStrings(config, "urls") {
  233. parsed, err := validateOutboundURL(ctx, destination, false)
  234. if err != nil {
  235. return err
  236. }
  237. var sendErr error
  238. for attempt := 0; attempt <= retries; attempt++ {
  239. request, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
  240. if requestErr != nil {
  241. return fmt.Errorf("create webhook notification request: %w", requestErr)
  242. }
  243. for name, value := range configStringMap(config, "headers") {
  244. request.Header.Set(name, value)
  245. }
  246. request.Header.Set("Content-Type", "application/json")
  247. request.Header.Set("User-Agent", "vocat-sms-notification/1")
  248. if secret := configString(config, "secret"); secret != "" {
  249. signature := hmac.New(sha256.New, []byte(secret))
  250. _, _ = signature.Write(payload)
  251. request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
  252. }
  253. sendErr = performNotificationRequest(client, request, false)
  254. if sendErr == nil {
  255. break
  256. }
  257. }
  258. if sendErr != nil {
  259. return sendErr
  260. }
  261. }
  262. return nil
  263. }
  264. func renderSMSWebhookTemplate(template string, message smsNotification) string {
  265. replacements := map[string]string{
  266. "{{text}}": message.Content,
  267. "{{content}}": message.Content,
  268. "{{event}}": "sms.received",
  269. "{{timestamp}}": message.Time.UTC().Format(time.RFC3339),
  270. "{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"),
  271. "{{number}}": message.Number,
  272. "{{device_id}}": message.DeviceID,
  273. "{{device_name}}": message.DeviceName,
  274. "{{device_label}}": message.DeviceLabel,
  275. }
  276. for placeholder, value := range replacements {
  277. template = strings.ReplaceAll(template, placeholder, value)
  278. }
  279. return template
  280. }
  281. func sendPushplusSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error {
  282. destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true)
  283. if err != nil {
  284. return err
  285. }
  286. payload := map[string]any{
  287. "token": configString(config, "token"),
  288. "title": "收到新短信",
  289. "content": message.DetailText(),
  290. "template": "txt",
  291. "timestamp": time.Now().UnixMilli(),
  292. }
  293. if topic := configString(config, "topic"); topic != "" {
  294. payload["topic"] = topic
  295. }
  296. if channel := configString(config, "channel"); channel != "" {
  297. payload["channel"] = channel
  298. }
  299. encoded, _ := json.Marshal(payload)
  300. client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
  301. if err != nil {
  302. return err
  303. }
  304. request, err := http.NewRequestWithContext(ctx, http.MethodPost, destination.String(), bytes.NewReader(encoded))
  305. if err != nil {
  306. return fmt.Errorf("create Pushplus notification request: %w", err)
  307. }
  308. request.Header.Set("Content-Type", "application/json; charset=utf-8")
  309. request.Header.Set("User-Agent", "vocat-sms-notification/1")
  310. response, err := client.Do(request)
  311. if err != nil {
  312. return fmt.Errorf("send Pushplus notification: %w", err)
  313. }
  314. defer response.Body.Close()
  315. body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
  316. if readErr != nil {
  317. return fmt.Errorf("read Pushplus response: %w", readErr)
  318. }
  319. var result struct {
  320. Code int `json:"code"`
  321. Msg string `json:"msg"`
  322. }
  323. if response.StatusCode < 200 || response.StatusCode >= 300 || json.Unmarshal(body, &result) != nil || result.Code != 200 {
  324. return fmt.Errorf("%w: Pushplus HTTP %d code %d %s", errProviderRejected, response.StatusCode, result.Code, result.Msg)
  325. }
  326. return nil
  327. }
  328. func sendEmailSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error {
  329. host := strings.TrimSpace(configString(config, "smtp_host"))
  330. port := configInt(config, "smtp_port")
  331. if port == 0 {
  332. port = 587
  333. }
  334. timeout := 8 * time.Second
  335. connection, err := dialRestricted(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
  336. if err != nil {
  337. return fmt.Errorf("connect SMTP server: %w", err)
  338. }
  339. defer connection.Close()
  340. if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
  341. return fmt.Errorf("set SMTP deadline: %w", err)
  342. }
  343. tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host}
  344. useSSL, _ := config["use_ssl"].(bool)
  345. implicitTLS := port == 465 || useSSL
  346. if implicitTLS {
  347. secure := tls.Client(connection, tlsConfig)
  348. if err := secure.HandshakeContext(ctx); err != nil {
  349. return fmt.Errorf("establish SMTP TLS: %w", err)
  350. }
  351. connection = secure
  352. }
  353. client, err := smtp.NewClient(connection, host)
  354. if err != nil {
  355. return fmt.Errorf("start SMTP session: %w", err)
  356. }
  357. defer client.Close()
  358. if !implicitTLS {
  359. if available, _ := client.Extension("STARTTLS"); !available {
  360. return errors.New("SMTP server does not offer STARTTLS")
  361. }
  362. if err := client.StartTLS(tlsConfig); err != nil {
  363. return fmt.Errorf("start SMTP TLS: %w", err)
  364. }
  365. }
  366. username, password := configString(config, "username"), configString(config, "password")
  367. if username != "" {
  368. if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
  369. return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
  370. }
  371. }
  372. from, err := mail.ParseAddress(configString(config, "from_address"))
  373. if err != nil {
  374. return fmt.Errorf("parse sender address: %w", err)
  375. }
  376. recipients := make([]*mail.Address, 0)
  377. for _, item := range configStrings(config, "to_addresses") {
  378. address, err := mail.ParseAddress(item)
  379. if err != nil {
  380. return fmt.Errorf("parse recipient address: %w", err)
  381. }
  382. recipients = append(recipients, address)
  383. }
  384. if err := client.Mail(from.Address); err != nil {
  385. return fmt.Errorf("%w: SMTP sender rejected", errProviderRejected)
  386. }
  387. for _, recipient := range recipients {
  388. if err := client.Rcpt(recipient.Address); err != nil {
  389. return fmt.Errorf("%w: SMTP recipient rejected", errProviderRejected)
  390. }
  391. }
  392. writer, err := client.Data()
  393. if err != nil {
  394. return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
  395. }
  396. email := strings.Join([]string{
  397. "Date: " + time.Now().UTC().Format(time.RFC1123Z),
  398. "From: " + from.String(),
  399. "To: " + joinMailAddresses(recipients),
  400. "Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel),
  401. "MIME-Version: 1.0",
  402. "Content-Type: text/plain; charset=UTF-8",
  403. "Content-Transfer-Encoding: 8bit",
  404. "",
  405. message.Text(),
  406. "",
  407. }, "\r\n")
  408. if _, err := io.WriteString(writer, email); err != nil {
  409. _ = writer.Close()
  410. return fmt.Errorf("write SMTP notification: %w", err)
  411. }
  412. if err := writer.Close(); err != nil {
  413. return fmt.Errorf("%w: SMTP message not accepted", errProviderRejected)
  414. }
  415. if err := client.Quit(); err != nil {
  416. return fmt.Errorf("finish SMTP session: %w", err)
  417. }
  418. return nil
  419. }