ntfy.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. // ============================================================================
  2. // ntfy.go — 工具页 ntfy 消息推送
  3. //
  4. // 把消息通过 ntfy 推送服务(默认 https://ntfy.sh)发送到指定主题(频道),
  5. // 用于给监听该主题的手机 / 浏览器 / 其他设备推送通知。
  6. // 主题输入会记忆最近使用过的主题(独立文件持久化),便于下拉选择。
  7. // ============================================================================
  8. package main
  9. import (
  10. "bufio"
  11. "bytes"
  12. "context"
  13. "encoding/json"
  14. "errors"
  15. "fmt"
  16. "io"
  17. "net/http"
  18. "net/url"
  19. "os"
  20. "path/filepath"
  21. "strings"
  22. "sync"
  23. "time"
  24. )
  25. // ntfy 默认推送服务器(ntfy.sh 公共实例)
  26. const ntfyServer = "https://ntfy.sh"
  27. // ntfy 主题历史最多记忆条数
  28. const ntfyTopicHistoryLimit = 20
  29. // ntfy 昵称历史最多记忆条数
  30. const ntfyNicknameHistoryLimit = 20
  31. // ntfyTopicsFile 返回主题历史文件路径:<用户配置目录>/TengTuToolbox/ntfy_topics.json
  32. func ntfyTopicsFile() (string, error) {
  33. dir, err := os.UserConfigDir()
  34. if err != nil {
  35. return "", err
  36. }
  37. return filepath.Join(dir, "TengTuToolbox", "ntfy_topics.json"), nil
  38. }
  39. // loadNtfyTopics 读取已记忆的主题列表(文件不存在或损坏时返回空列表)
  40. func loadNtfyTopics() []string {
  41. path, err := ntfyTopicsFile()
  42. if err != nil {
  43. return nil
  44. }
  45. data, err := os.ReadFile(path)
  46. if err != nil {
  47. return nil
  48. }
  49. var topics []string
  50. if err := json.Unmarshal(data, &topics); err != nil {
  51. return nil
  52. }
  53. return topics
  54. }
  55. // saveNtfyTopics 把主题列表写回历史文件(目录不存在时自动创建)
  56. func saveNtfyTopics(topics []string) error {
  57. path, err := ntfyTopicsFile()
  58. if err != nil {
  59. return err
  60. }
  61. if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
  62. return err
  63. }
  64. data, err := json.MarshalIndent(topics, "", " ")
  65. if err != nil {
  66. return err
  67. }
  68. return os.WriteFile(path, data, 0o644)
  69. }
  70. var ntfyMu sync.Mutex // 保护主题历史的并发读写
  71. // ntfyNicknamesFile 返回昵称历史文件路径:<用户配置目录>/TengTuToolbox/ntfy_nicknames.json
  72. func ntfyNicknamesFile() (string, error) {
  73. dir, err := os.UserConfigDir()
  74. if err != nil {
  75. return "", err
  76. }
  77. return filepath.Join(dir, "TengTuToolbox", "ntfy_nicknames.json"), nil
  78. }
  79. // loadNtfyNicknames 读取已记忆的昵称列表(文件不存在或损坏时返回空列表)
  80. func loadNtfyNicknames() []string {
  81. path, err := ntfyNicknamesFile()
  82. if err != nil {
  83. return nil
  84. }
  85. data, err := os.ReadFile(path)
  86. if err != nil {
  87. return nil
  88. }
  89. var nicknames []string
  90. if err := json.Unmarshal(data, &nicknames); err != nil {
  91. return nil
  92. }
  93. return nicknames
  94. }
  95. // saveNtfyNicknames 把昵称列表写回历史文件(目录不存在时自动创建)
  96. func saveNtfyNicknames(nicknames []string) error {
  97. path, err := ntfyNicknamesFile()
  98. if err != nil {
  99. return err
  100. }
  101. if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
  102. return err
  103. }
  104. data, err := json.MarshalIndent(nicknames, "", " ")
  105. if err != nil {
  106. return err
  107. }
  108. return os.WriteFile(path, data, 0o644)
  109. }
  110. // AddNtfyNickname 记录一个昵称:去重、置顶、上限 ntfyNicknameHistoryLimit 条,返回更新后列表。
  111. // 昵称可为空(不记忆空昵称)。
  112. func AddNtfyNickname(nickname string) []string {
  113. nickname = strings.TrimSpace(nickname)
  114. if nickname == "" {
  115. return loadNtfyNicknames()
  116. }
  117. ntfyMu.Lock()
  118. defer ntfyMu.Unlock()
  119. nicknames := loadNtfyNicknames()
  120. next := []string{nickname}
  121. for _, n := range nicknames {
  122. if n != nickname {
  123. next = append(next, n)
  124. }
  125. }
  126. if len(next) > ntfyNicknameHistoryLimit {
  127. next = next[:ntfyNicknameHistoryLimit]
  128. }
  129. if err := saveNtfyNicknames(next); err != nil {
  130. return nicknames
  131. }
  132. return next
  133. }
  134. // GetNtfyNicknames 返回已记忆的昵称列表(用于前端下拉建议)
  135. func (s *SettingsService) GetNtfyNicknames() []string {
  136. ntfyMu.Lock()
  137. defer ntfyMu.Unlock()
  138. return loadNtfyNicknames()
  139. }
  140. // SendNtfyChat 向聊天室(主题)发送一条消息,nickname 作为标题发送。
  141. // 与 SendNtfyMessage 的区别:昵称固定作为标题,便于聊天室按昵称区分发言者。
  142. func (s *SettingsService) SendNtfyChat(topic, nickname, message string) error {
  143. nickname = strings.TrimSpace(nickname)
  144. message = strings.TrimSpace(message)
  145. err := s.SendNtfyMessage(topic, nickname, message)
  146. if err != nil {
  147. return err
  148. }
  149. AddNtfyNickname(nickname) // 发送成功后记忆昵称
  150. rememberLocalChat(topic, message) // 记录本地发送,用于去重 SSE 回声
  151. return nil
  152. }
  153. // SendNtfyMessage 向指定主题推送一条 ntfy 消息。
  154. // topic 为推送主题(频道名),title 可为空(使用主题名),message 为消息内容。
  155. func (s *SettingsService) SendNtfyMessage(topic, title, message string) error {
  156. topic = strings.TrimSpace(topic)
  157. message = strings.TrimSpace(message)
  158. if topic == "" {
  159. return errors.New("请输入推送主题(Topic)")
  160. }
  161. if message == "" {
  162. return errors.New("请输入消息内容")
  163. }
  164. if len(topic) > 64 || len(message) > 4000 {
  165. return errors.New("主题过长或内容过长(主题 ≤64、内容 ≤4000 字符)")
  166. }
  167. // 校验主题为合法 URL 路径片段(允许字母数字、- _ .)
  168. for _, r := range topic {
  169. if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
  170. return errors.New("主题只能包含字母、数字、-、_、. 字符")
  171. }
  172. }
  173. body := bytes.NewBufferString(message)
  174. req, err := http.NewRequest(http.MethodPost, ntfyServer+"/"+url.PathEscape(topic), body)
  175. if err != nil {
  176. return err
  177. }
  178. req.Header.Set("Content-Type", "text/plain; charset=utf-8")
  179. if strings.TrimSpace(title) != "" {
  180. req.Header.Set("Title", strings.TrimSpace(title)) // 通知标题,缺省用主题名
  181. }
  182. client := &http.Client{Timeout: 15 * time.Second}
  183. resp, err := client.Do(req)
  184. if err != nil {
  185. return errors.New("推送请求失败:" + err.Error())
  186. }
  187. defer resp.Body.Close()
  188. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  189. raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
  190. return errors.New("推送失败,状态 " + resp.Status + ":" + strings.TrimSpace(string(raw)))
  191. }
  192. // 发送成功后记忆主题(去重置顶)
  193. AddNtfyTopic(topic)
  194. return nil
  195. }
  196. // GetNtfyTopics 返回已记忆的主题列表(用于前端下拉建议)
  197. func (s *SettingsService) GetNtfyTopics() []string {
  198. ntfyMu.Lock()
  199. defer ntfyMu.Unlock()
  200. return loadNtfyTopics()
  201. }
  202. // AddNtfyTopic 记录一个主题:去重、置顶、上限 ntfyTopicHistoryLimit 条,返回更新后列表。
  203. // 发送失败的主题不记录;仅在成功发送时调用。
  204. func AddNtfyTopic(topic string) []string {
  205. topic = strings.TrimSpace(topic)
  206. if topic == "" {
  207. return loadNtfyTopics()
  208. }
  209. ntfyMu.Lock()
  210. defer ntfyMu.Unlock()
  211. topics := loadNtfyTopics()
  212. next := []string{topic}
  213. for _, t := range topics {
  214. if t != topic {
  215. next = append(next, t)
  216. }
  217. }
  218. if len(next) > ntfyTopicHistoryLimit {
  219. next = next[:ntfyTopicHistoryLimit] // 只保留最近 N 条
  220. }
  221. if err := saveNtfyTopics(next); err != nil {
  222. // 持久化失败不影响本次返回(仅记录历史)
  223. return topics
  224. }
  225. return next
  226. }
  227. // ============================================================================
  228. // ntfy 聊天室:订阅一个主题,实时监听该主题收到的消息并广播给前端
  229. // ============================================================================
  230. // NtfyChatMessage 聊天室收到的一条消息(JSON 序列化后经 ntfyChat 事件发给前端)
  231. type NtfyChatMessage struct {
  232. Topic string `json:"topic"` // 所属主题(聊天室名)
  233. Title string `json:"title"` // 通知标题(可为空)
  234. Message string `json:"message"` // 消息内容
  235. Time string `json:"time"` // 收到时间(本地时区 HH:MM:SS)
  236. }
  237. // ntfyEchoRecent 记录近期本地发送的消息,用于去重 ntfy 的 SSE 回声:
  238. // 自己发送的消息会被 ntfy 经 SSE 回传一次,若不加去重会在前端重复显示。
  239. type ntfyEchoRecord struct {
  240. Topic string
  241. Message string
  242. At time.Time
  243. }
  244. var (
  245. ntfyEchoMu sync.Mutex
  246. ntfyEchoRecent []ntfyEchoRecord
  247. ntfyEchoWindow = 8 * time.Second // 8 秒内的同主题同内容消息视为回声
  248. ntfyEchoMax = 20 // 最多记忆条数
  249. )
  250. // rememberLocalChat 记录一次本地发送(由 SendNtfyChat 成功发送后调用)。
  251. func rememberLocalChat(topic, message string) {
  252. ntfyEchoMu.Lock()
  253. defer ntfyEchoMu.Unlock()
  254. now := time.Now()
  255. // 先清理过期记录,再追加
  256. valid := ntfyEchoRecent[:0]
  257. for _, r := range ntfyEchoRecent {
  258. if now.Sub(r.At) < ntfyEchoWindow {
  259. valid = append(valid, r)
  260. }
  261. }
  262. valid = append(valid, ntfyEchoRecord{Topic: topic, Message: message, At: now})
  263. if len(valid) > ntfyEchoMax {
  264. valid = valid[len(valid)-ntfyEchoMax:]
  265. }
  266. ntfyEchoRecent = valid
  267. }
  268. // consumeEchoChat 检查一条收到的 SSE 消息是否为本地发送的回声。
  269. // 若是(8 秒内同主题同内容),消费并返回 true,调用方应丢弃该消息。
  270. func consumeEchoChat(topic, message string) bool {
  271. ntfyEchoMu.Lock()
  272. defer ntfyEchoMu.Unlock()
  273. now := time.Now()
  274. for i, r := range ntfyEchoRecent {
  275. if now.Sub(r.At) < ntfyEchoWindow && r.Topic == topic && r.Message == message {
  276. // 移除该条,避免后续重复命中
  277. ntfyEchoRecent = append(ntfyEchoRecent[:i], ntfyEchoRecent[i+1:]...)
  278. return true
  279. }
  280. }
  281. return false
  282. }
  283. // ntfyChatState 聊天室监听状态:当前订阅的主题与取消函数
  284. type ntfyChatState struct {
  285. mu sync.Mutex
  286. topic string // 当前监听的主题,空表示未监听
  287. cancel func() // 取消当前监听(关闭 HTTP 响应体)
  288. }
  289. var ntfyChat = &ntfyChatState{}
  290. // SetNtfyChatTopic 设置聊天室要监听的主题。
  291. // 传入空字符串则停止监听(无主题不监听)。
  292. // 若与当前监听主题相同则不做任何事;不同则先停止旧监听再启动新监听。
  293. func (s *SettingsService) SetNtfyChatTopic(topic string) error {
  294. topic = strings.TrimSpace(topic)
  295. if topic == "" {
  296. StopNtfyChat()
  297. return nil
  298. }
  299. // 主题合法性校验(与发送一致)
  300. for _, r := range topic {
  301. if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
  302. return errors.New("主题只能包含字母、数字、-、_、. 字符")
  303. }
  304. }
  305. ntfyChat.mu.Lock()
  306. defer ntfyChat.mu.Unlock()
  307. if ntfyChat.topic == topic {
  308. return nil // 已在监听该主题,无需重启
  309. }
  310. // 停止旧的监听
  311. if ntfyChat.cancel != nil {
  312. ntfyChat.cancel()
  313. ntfyChat.cancel = nil
  314. }
  315. // 启动新监听
  316. ctx, cancel := context.WithCancel(context.Background())
  317. ntfyChat.topic = topic
  318. ntfyChat.cancel = cancel
  319. go listenNtfyChat(ctx, topic)
  320. return nil
  321. }
  322. // StopNtfyChat 停止聊天室监听(清空主题并取消请求)
  323. func StopNtfyChat() {
  324. ntfyChat.mu.Lock()
  325. defer ntfyChat.mu.Unlock()
  326. if ntfyChat.cancel != nil {
  327. ntfyChat.cancel()
  328. ntfyChat.cancel = nil
  329. }
  330. ntfyChat.topic = ""
  331. }
  332. // GetNtfyChatTopic 返回当前监听的聊天室主题(空表示未监听)
  333. func (s *SettingsService) GetNtfyChatTopic() string {
  334. ntfyChat.mu.Lock()
  335. defer ntfyChat.mu.Unlock()
  336. return ntfyChat.topic
  337. }
  338. // listenNtfyChat 用 SSE 长连接订阅 ntfy 主题,循环读取消息并广播 ntfyChat 事件。
  339. // ctx 取消时关闭请求退出;连接中断后按 backoff 自动重连,直到被取消。
  340. func listenNtfyChat(ctx context.Context, topic string) {
  341. client := &http.Client{}
  342. backoff := time.Second
  343. for {
  344. select {
  345. case <-ctx.Done():
  346. return // 被取消,退出
  347. default:
  348. }
  349. err := ntfyChatOnce(client, ctx, topic, backoff)
  350. // 连接结束(错误或中断),增大重连间隔,若已被取消则退出
  351. select {
  352. case <-ctx.Done():
  353. return
  354. default:
  355. }
  356. if backoff < 30*time.Second {
  357. backoff += 5 * time.Second // 逐步增大重连间隔
  358. }
  359. _ = err
  360. }
  361. }
  362. // ntfyChatOnce 建立一次 SSE 连接并阻塞读取消息,直到连接结束或被取消。
  363. // 成功接收任意一条消息后重置 backoff(由调用方在收到消息时处理)。
  364. func ntfyChatOnce(client *http.Client, ctx context.Context, topic string, backoff time.Duration) error {
  365. req, err := http.NewRequestWithContext(ctx, http.MethodGet, ntfyServer+"/"+url.PathEscape(topic)+"/sse", nil)
  366. if err != nil {
  367. return err
  368. }
  369. req.Header.Set("Accept", "text/event-stream")
  370. resp, err := client.Do(req)
  371. if err != nil {
  372. return err
  373. }
  374. defer resp.Body.Close()
  375. // 非 2xx:不重试(如主题非法 / 服务拒绝),直接返回错误
  376. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  377. raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
  378. return fmt.Errorf("subscribe failed: %s %s", resp.Status, strings.TrimSpace(string(raw)))
  379. }
  380. scanner := bufio.NewScanner(resp.Body)
  381. scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 单行最大 1MB,容纳长消息
  382. // SSE 字段:ntfy.sh 只发 data: 行(内含 JSON,事件类型在 JSON 的 event 键),
  383. // 没有独立的 event: 字段;空行分隔一条事件。keepalive 为注释行(: ping),不含 data:。
  384. var data string
  385. for scanner.Scan() {
  386. line := scanner.Text()
  387. if line == "" {
  388. // 一条 SSE 事件结束
  389. if data != "" {
  390. emitNtfyChat(topic, data)
  391. }
  392. data = ""
  393. continue
  394. }
  395. if strings.HasPrefix(line, "data:") {
  396. data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
  397. }
  398. }
  399. return scanner.Err()
  400. }
  401. // emitNtfyChat 解析一条消息 data 并广播 ntfyChat 事件给前端。
  402. // ntfy 的 SSE data 为 JSON,内含 event 字段:只处理 message 事件,忽略 open/keepalive 等。
  403. func emitNtfyChat(topic, data string) {
  404. // data 可能是 JSON(发布时带标题 / event 字段)或纯文本。
  405. // 注意:ntfy JSON 的 time 字段是数字,必须用 map[string]any 解析,
  406. // 若用 map[string]string 会因 number→string 失败导致解析整个失败,
  407. // 从而把整条原始 JSON 当作消息回退显示(表现为「直接显示 ntfy 消息」)。
  408. var title, msg string
  409. showTime := time.Now().Format("15:04:05") // 默认用收到时间
  410. var raw map[string]any
  411. if json.Unmarshal([]byte(data), &raw) == nil {
  412. // 仅处理 message 事件;无 event 字段时按纯文本消息处理
  413. if ev, ok := raw["event"].(string); ok && ev != "message" {
  414. return // open / keepalive 等非消息事件,忽略
  415. }
  416. if v, ok := raw["message"].(string); ok {
  417. msg = v
  418. }
  419. if v, ok := raw["title"].(string); ok {
  420. title = v
  421. }
  422. // ntfy 的 time 为 Unix 时间戳(秒),转本地时间显示发送时刻
  423. if ts, ok := raw["time"].(float64); ok && ts > 0 {
  424. showTime = time.Unix(int64(ts), 0).Format("15:04:05")
  425. }
  426. }
  427. if msg == "" {
  428. msg = data // 纯文本消息
  429. }
  430. if title == "" {
  431. title = topic // 无标题时显示主题名
  432. }
  433. // 去重 SSE 回声:自己刚发送的消息会被 ntfy 回传一次,
  434. // 若 8 秒内同主题同内容则判定为回声,直接丢弃(本地已显示)。
  435. if consumeEchoChat(topic, msg) {
  436. return
  437. }
  438. payload := NtfyChatMessage{
  439. Topic: topic,
  440. Title: title,
  441. Message: msg,
  442. Time: showTime,
  443. }
  444. b, _ := json.Marshal(payload)
  445. if app != nil {
  446. app.Event.Emit("ntfyChat", string(b))
  447. }
  448. }