ntfy.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  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. topic = strings.TrimSpace(topic)
  145. message = strings.TrimSpace(message)
  146. // 先记录本地发送,再发送。
  147. // 关键:ntfy 服务器收到消息后会立即通过 SSE 广播回声,往往比 HTTP 响应
  148. // 更快到达。若在发送成功后才记录,回声到达时记录尚不存在,去重必然失败
  149. // → 自己发送的消息又被当作 SSE 消息显示一次(表现为「显示两次」)。
  150. // 因此必须「先记录、后发送」,保证 SSE 回声到达时去重记录已就位。
  151. rememberLocalChat(topic, message)
  152. logDebug("SENT remember: topic=%q msg=%q", topic, message)
  153. err := s.SendNtfyMessage(topic, nickname, message)
  154. if err != nil {
  155. // 发送失败:移除刚记录的回声,避免误吞后续同主题同内容的真实消息
  156. forgetLocalChat(topic, message)
  157. return err
  158. }
  159. AddNtfyNickname(nickname) // 发送成功后记忆昵称
  160. return nil
  161. }
  162. // SendNtfyMessage 向指定主题推送一条 ntfy 消息。
  163. // topic 为推送主题(频道名),title 可为空(使用主题名),message 为消息内容。
  164. func (s *SettingsService) SendNtfyMessage(topic, title, message string) error {
  165. topic = strings.TrimSpace(topic)
  166. message = strings.TrimSpace(message)
  167. if topic == "" {
  168. return errors.New("请输入推送主题(Topic)")
  169. }
  170. if message == "" {
  171. return errors.New("请输入消息内容")
  172. }
  173. if len(topic) > 64 || len(message) > 4000 {
  174. return errors.New("主题过长或内容过长(主题 ≤64、内容 ≤4000 字符)")
  175. }
  176. // 校验主题为合法 URL 路径片段(允许字母数字、- _ .)
  177. for _, r := range topic {
  178. if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
  179. return errors.New("主题只能包含字母、数字、-、_、. 字符")
  180. }
  181. }
  182. body := bytes.NewBufferString(message)
  183. req, err := http.NewRequest(http.MethodPost, ntfyServer+"/"+url.PathEscape(topic), body)
  184. if err != nil {
  185. return err
  186. }
  187. req.Header.Set("Content-Type", "text/plain; charset=utf-8")
  188. if strings.TrimSpace(title) != "" {
  189. req.Header.Set("Title", strings.TrimSpace(title)) // 通知标题,缺省用主题名
  190. }
  191. client := &http.Client{Timeout: 15 * time.Second}
  192. resp, err := client.Do(req)
  193. if err != nil {
  194. return errors.New("推送请求失败:" + err.Error())
  195. }
  196. defer resp.Body.Close()
  197. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  198. raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
  199. return errors.New("推送失败,状态 " + resp.Status + ":" + strings.TrimSpace(string(raw)))
  200. }
  201. // 发送成功后记忆主题(去重置顶)
  202. AddNtfyTopic(topic)
  203. return nil
  204. }
  205. // GetNtfyTopics 返回已记忆的主题列表(用于前端下拉建议)
  206. func (s *SettingsService) GetNtfyTopics() []string {
  207. ntfyMu.Lock()
  208. defer ntfyMu.Unlock()
  209. return loadNtfyTopics()
  210. }
  211. // AddNtfyTopic 记录一个主题:去重、置顶、上限 ntfyTopicHistoryLimit 条,返回更新后列表。
  212. // 发送失败的主题不记录;仅在成功发送时调用。
  213. func AddNtfyTopic(topic string) []string {
  214. topic = strings.TrimSpace(topic)
  215. if topic == "" {
  216. return loadNtfyTopics()
  217. }
  218. ntfyMu.Lock()
  219. defer ntfyMu.Unlock()
  220. topics := loadNtfyTopics()
  221. next := []string{topic}
  222. for _, t := range topics {
  223. if t != topic {
  224. next = append(next, t)
  225. }
  226. }
  227. if len(next) > ntfyTopicHistoryLimit {
  228. next = next[:ntfyTopicHistoryLimit] // 只保留最近 N 条
  229. }
  230. if err := saveNtfyTopics(next); err != nil {
  231. // 持久化失败不影响本次返回(仅记录历史)
  232. return topics
  233. }
  234. return next
  235. }
  236. // ============================================================================
  237. // ntfy 聊天室:订阅一个主题,实时监听该主题收到的消息并广播给前端
  238. // ============================================================================
  239. // NtfyChatMessage 聊天室收到的一条消息(JSON 序列化后经 ntfyChat 事件发给前端)
  240. type NtfyChatMessage struct {
  241. Topic string `json:"topic"` // 所属主题(聊天室名)
  242. Title string `json:"title"` // 通知标题(可为空)
  243. Message string `json:"message"` // 消息内容
  244. Time string `json:"time"` // 收到时间(本地时区 HH:MM:SS)
  245. }
  246. // ntfyEchoRecent 记录近期本地发送的消息,用于去重 ntfy 的 SSE 回声:
  247. // 自己发送的消息会被 ntfy 经 SSE 回传一次,若不加去重会在前端重复显示。
  248. type ntfyEchoRecord struct {
  249. Topic string
  250. Message string
  251. At time.Time
  252. }
  253. var (
  254. ntfyEchoMu sync.Mutex
  255. ntfyEchoRecent []ntfyEchoRecord
  256. ntfyEchoWindow = 8 * time.Second // 8 秒内的同主题同内容消息视为回声
  257. ntfyEchoMax = 20 // 最多记忆条数
  258. )
  259. // rememberLocalChat 记录一次本地发送(由 SendNtfyChat 成功发送后调用)。
  260. func rememberLocalChat(topic, message string) {
  261. ntfyEchoMu.Lock()
  262. defer ntfyEchoMu.Unlock()
  263. now := time.Now()
  264. // 先清理过期记录,再追加
  265. valid := ntfyEchoRecent[:0]
  266. for _, r := range ntfyEchoRecent {
  267. if now.Sub(r.At) < ntfyEchoWindow {
  268. valid = append(valid, r)
  269. }
  270. }
  271. valid = append(valid, ntfyEchoRecord{Topic: topic, Message: message, At: now})
  272. if len(valid) > ntfyEchoMax {
  273. valid = valid[len(valid)-ntfyEchoMax:]
  274. }
  275. ntfyEchoRecent = valid
  276. }
  277. // consumeEchoChat 检查一条收到的 SSE 消息是否为本地发送的回声。
  278. // 若是(8 秒内同主题同内容),消费并返回 true,调用方应丢弃该消息。
  279. func consumeEchoChat(topic, message string) bool {
  280. ntfyEchoMu.Lock()
  281. defer ntfyEchoMu.Unlock()
  282. now := time.Now()
  283. for i, r := range ntfyEchoRecent {
  284. if now.Sub(r.At) < ntfyEchoWindow && r.Topic == topic && r.Message == message {
  285. // 移除该条,避免后续重复命中
  286. ntfyEchoRecent = append(ntfyEchoRecent[:i], ntfyEchoRecent[i+1:]...)
  287. return true
  288. }
  289. }
  290. return false
  291. }
  292. // forgetLocalChat 移除一条本地发送记录(发送失败时回滚)。
  293. // 若发送失败,SSE 不会收到回声,需移除记录避免误吞后续同主题同内容的真实消息。
  294. func forgetLocalChat(topic, message string) {
  295. ntfyEchoMu.Lock()
  296. defer ntfyEchoMu.Unlock()
  297. for i, r := range ntfyEchoRecent {
  298. if r.Topic == topic && r.Message == message {
  299. ntfyEchoRecent = append(ntfyEchoRecent[:i], ntfyEchoRecent[i+1:]...)
  300. return
  301. }
  302. }
  303. }
  304. // ntfyChatState 聊天室监听状态:当前订阅的主题与取消函数
  305. type ntfyChatState struct {
  306. mu sync.Mutex
  307. topic string // 当前监听的主题,空表示未监听
  308. cancel func() // 取消当前监听(关闭 HTTP 响应体)
  309. }
  310. var ntfyChat = &ntfyChatState{}
  311. // SetNtfyChatTopic 设置聊天室要监听的主题。
  312. // 传入空字符串则停止监听(无主题不监听)。
  313. // 若与当前监听主题相同则不做任何事;不同则先停止旧监听再启动新监听。
  314. func (s *SettingsService) SetNtfyChatTopic(topic string) error {
  315. topic = strings.TrimSpace(topic)
  316. if topic == "" {
  317. StopNtfyChat()
  318. return nil
  319. }
  320. // 主题合法性校验(与发送一致)
  321. for _, r := range topic {
  322. if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
  323. return errors.New("主题只能包含字母、数字、-、_、. 字符")
  324. }
  325. }
  326. ntfyChat.mu.Lock()
  327. defer ntfyChat.mu.Unlock()
  328. if ntfyChat.topic == topic {
  329. return nil // 已在监听该主题,无需重启
  330. }
  331. // 停止旧的监听
  332. if ntfyChat.cancel != nil {
  333. ntfyChat.cancel()
  334. ntfyChat.cancel = nil
  335. }
  336. // 启动新监听
  337. ctx, cancel := context.WithCancel(context.Background())
  338. ntfyChat.topic = topic
  339. ntfyChat.cancel = cancel
  340. go listenNtfyChat(ctx, topic)
  341. return nil
  342. }
  343. // StopNtfyChat 停止聊天室监听(清空主题并取消请求)
  344. func StopNtfyChat() {
  345. ntfyChat.mu.Lock()
  346. defer ntfyChat.mu.Unlock()
  347. if ntfyChat.cancel != nil {
  348. ntfyChat.cancel()
  349. ntfyChat.cancel = nil
  350. }
  351. ntfyChat.topic = ""
  352. }
  353. // GetNtfyChatTopic 返回当前监听的聊天室主题(空表示未监听)
  354. func (s *SettingsService) GetNtfyChatTopic() string {
  355. ntfyChat.mu.Lock()
  356. defer ntfyChat.mu.Unlock()
  357. return ntfyChat.topic
  358. }
  359. // listenNtfyChat 用 SSE 长连接订阅 ntfy 主题,循环读取消息并广播 ntfyChat 事件。
  360. // ctx 取消时关闭请求退出;连接中断后按 backoff 自动重连,直到被取消。
  361. func listenNtfyChat(ctx context.Context, topic string) {
  362. client := &http.Client{}
  363. backoff := time.Second
  364. for {
  365. select {
  366. case <-ctx.Done():
  367. return // 被取消,退出
  368. default:
  369. }
  370. err := ntfyChatOnce(client, ctx, topic, backoff)
  371. // 连接结束(错误或中断),增大重连间隔,若已被取消则退出
  372. select {
  373. case <-ctx.Done():
  374. return
  375. default:
  376. }
  377. if backoff < 30*time.Second {
  378. backoff += 5 * time.Second // 逐步增大重连间隔
  379. }
  380. _ = err
  381. }
  382. }
  383. // ntfyChatOnce 建立一次 SSE 连接并阻塞读取消息,直到连接结束或被取消。
  384. // 成功接收任意一条消息后重置 backoff(由调用方在收到消息时处理)。
  385. func ntfyChatOnce(client *http.Client, ctx context.Context, topic string, backoff time.Duration) error {
  386. req, err := http.NewRequestWithContext(ctx, http.MethodGet, ntfyServer+"/"+url.PathEscape(topic)+"/sse", nil)
  387. if err != nil {
  388. return err
  389. }
  390. req.Header.Set("Accept", "text/event-stream")
  391. resp, err := client.Do(req)
  392. if err != nil {
  393. return err
  394. }
  395. defer resp.Body.Close()
  396. // 非 2xx:不重试(如主题非法 / 服务拒绝),直接返回错误
  397. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  398. raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
  399. return fmt.Errorf("subscribe failed: %s %s", resp.Status, strings.TrimSpace(string(raw)))
  400. }
  401. scanner := bufio.NewScanner(resp.Body)
  402. scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 单行最大 1MB,容纳长消息
  403. // SSE 字段:ntfy.sh 只发 data: 行(内含 JSON,事件类型在 JSON 的 event 键),
  404. // 没有独立的 event: 字段;空行分隔一条事件。keepalive 为注释行(: ping),不含 data:。
  405. var data string
  406. for scanner.Scan() {
  407. line := scanner.Text()
  408. if line == "" {
  409. // 一条 SSE 事件结束
  410. if data != "" {
  411. emitNtfyChat(topic, data)
  412. }
  413. data = ""
  414. continue
  415. }
  416. if strings.HasPrefix(line, "data:") {
  417. data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
  418. }
  419. }
  420. return scanner.Err()
  421. }
  422. // emitNtfyChat 解析一条消息 data 并广播 ntfyChat 事件给前端。
  423. // ntfy 的 SSE data 为 JSON,内含 event 字段:只处理 message 事件,忽略 open/keepalive 等。
  424. func emitNtfyChat(topic, data string) {
  425. // data 可能是 JSON(发布时带标题 / event 字段)或纯文本。
  426. // 注意:ntfy JSON 的 time 字段是数字,必须用 map[string]any 解析,
  427. // 若用 map[string]string 会因 number→string 失败导致解析整个失败,
  428. // 从而把整条原始 JSON 当作消息回退显示(表现为「直接显示 ntfy 消息」)。
  429. var title, msg string
  430. showTime := time.Now().Format("15:04:05") // 默认用收到时间
  431. var raw map[string]any
  432. if json.Unmarshal([]byte(data), &raw) == nil {
  433. // 仅处理 message 事件;无 event 字段时按纯文本消息处理
  434. if ev, ok := raw["event"].(string); ok && ev != "message" {
  435. return // open / keepalive 等非消息事件,忽略
  436. }
  437. if v, ok := raw["message"].(string); ok {
  438. msg = v
  439. }
  440. if v, ok := raw["title"].(string); ok {
  441. title = v
  442. }
  443. // ntfy 的 time 为 Unix 时间戳(秒),转本地时间显示发送时刻
  444. if ts, ok := raw["time"].(float64); ok && ts > 0 {
  445. showTime = time.Unix(int64(ts), 0).Format("15:04:05")
  446. }
  447. }
  448. if msg == "" {
  449. msg = data // 纯文本消息
  450. }
  451. if title == "" {
  452. title = topic // 无标题时显示主题名
  453. }
  454. // 去重 SSE 回声:自己刚发送的消息会被 ntfy 回传一次,
  455. // 若 8 秒内同主题同内容则判定为回声,直接丢弃(本地已显示)。
  456. if consumeEchoChat(topic, msg) {
  457. logDebug("ECHO dropped: listenTopic=%q title=%q msg=%q", topic, title, msg)
  458. return
  459. }
  460. logDebug("ECHO broadcast: listenTopic=%q title=%q msg=%q", topic, title, msg)
  461. payload := NtfyChatMessage{
  462. Topic: topic,
  463. Title: title,
  464. Message: msg,
  465. Time: showTime,
  466. }
  467. b, _ := json.Marshal(payload)
  468. if app != nil {
  469. app.Event.Emit("ntfyChat", string(b))
  470. }
  471. }
  472. // logDebug 把调试日志追加写入 %TEMP%\ntfy_debug.log(临时诊断,定位后移除)。
  473. func logDebug(format string, args ...any) {
  474. dir := os.Getenv("TEMP")
  475. if dir == "" {
  476. dir = os.TempDir()
  477. }
  478. path := filepath.Join(dir, "ntfy_debug.log")
  479. f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
  480. if err != nil {
  481. return
  482. }
  483. defer f.Close()
  484. fmt.Fprintf(f, "%s %s\n", time.Now().Format("15:04:05.000"), fmt.Sprintf(format, args...))
  485. }