| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539 |
- // ============================================================================
- // ntfy.go — 工具页 ntfy 消息推送
- //
- // 把消息通过 ntfy 推送服务(默认 https://ntfy.sh)发送到指定主题(频道),
- // 用于给监听该主题的手机 / 浏览器 / 其他设备推送通知。
- // 主题输入会记忆最近使用过的主题(独立文件持久化),便于下拉选择。
- // ============================================================================
- package main
- import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "time"
- )
- // ntfy 默认推送服务器(ntfy.sh 公共实例)
- const ntfyServer = "https://ntfy.sh"
- // ntfy 主题历史最多记忆条数
- const ntfyTopicHistoryLimit = 20
- // ntfy 昵称历史最多记忆条数
- const ntfyNicknameHistoryLimit = 20
- // ntfyTopicsFile 返回主题历史文件路径:<用户配置目录>/TengTuToolbox/ntfy_topics.json
- func ntfyTopicsFile() (string, error) {
- dir, err := os.UserConfigDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(dir, "TengTuToolbox", "ntfy_topics.json"), nil
- }
- // loadNtfyTopics 读取已记忆的主题列表(文件不存在或损坏时返回空列表)
- func loadNtfyTopics() []string {
- path, err := ntfyTopicsFile()
- if err != nil {
- return nil
- }
- data, err := os.ReadFile(path)
- if err != nil {
- return nil
- }
- var topics []string
- if err := json.Unmarshal(data, &topics); err != nil {
- return nil
- }
- return topics
- }
- // saveNtfyTopics 把主题列表写回历史文件(目录不存在时自动创建)
- func saveNtfyTopics(topics []string) error {
- path, err := ntfyTopicsFile()
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- data, err := json.MarshalIndent(topics, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile(path, data, 0o644)
- }
- var ntfyMu sync.Mutex // 保护主题历史的并发读写
- // ntfyNicknamesFile 返回昵称历史文件路径:<用户配置目录>/TengTuToolbox/ntfy_nicknames.json
- func ntfyNicknamesFile() (string, error) {
- dir, err := os.UserConfigDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(dir, "TengTuToolbox", "ntfy_nicknames.json"), nil
- }
- // loadNtfyNicknames 读取已记忆的昵称列表(文件不存在或损坏时返回空列表)
- func loadNtfyNicknames() []string {
- path, err := ntfyNicknamesFile()
- if err != nil {
- return nil
- }
- data, err := os.ReadFile(path)
- if err != nil {
- return nil
- }
- var nicknames []string
- if err := json.Unmarshal(data, &nicknames); err != nil {
- return nil
- }
- return nicknames
- }
- // saveNtfyNicknames 把昵称列表写回历史文件(目录不存在时自动创建)
- func saveNtfyNicknames(nicknames []string) error {
- path, err := ntfyNicknamesFile()
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- data, err := json.MarshalIndent(nicknames, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile(path, data, 0o644)
- }
- // AddNtfyNickname 记录一个昵称:去重、置顶、上限 ntfyNicknameHistoryLimit 条,返回更新后列表。
- // 昵称可为空(不记忆空昵称)。
- func AddNtfyNickname(nickname string) []string {
- nickname = strings.TrimSpace(nickname)
- if nickname == "" {
- return loadNtfyNicknames()
- }
- ntfyMu.Lock()
- defer ntfyMu.Unlock()
- nicknames := loadNtfyNicknames()
- next := []string{nickname}
- for _, n := range nicknames {
- if n != nickname {
- next = append(next, n)
- }
- }
- if len(next) > ntfyNicknameHistoryLimit {
- next = next[:ntfyNicknameHistoryLimit]
- }
- if err := saveNtfyNicknames(next); err != nil {
- return nicknames
- }
- return next
- }
- // GetNtfyNicknames 返回已记忆的昵称列表(用于前端下拉建议)
- func (s *SettingsService) GetNtfyNicknames() []string {
- ntfyMu.Lock()
- defer ntfyMu.Unlock()
- return loadNtfyNicknames()
- }
- // SendNtfyChat 向聊天室(主题)发送一条消息,nickname 作为标题发送。
- // 与 SendNtfyMessage 的区别:昵称固定作为标题,便于聊天室按昵称区分发言者。
- func (s *SettingsService) SendNtfyChat(topic, nickname, message string) error {
- nickname = strings.TrimSpace(nickname)
- topic = strings.TrimSpace(topic)
- message = strings.TrimSpace(message)
- // 先记录本地发送,再发送。
- // 关键:ntfy 服务器收到消息后会立即通过 SSE 广播回声,往往比 HTTP 响应
- // 更快到达。若在发送成功后才记录,回声到达时记录尚不存在,去重必然失败
- // → 自己发送的消息又被当作 SSE 消息显示一次(表现为「显示两次」)。
- // 因此必须「先记录、后发送」,保证 SSE 回声到达时去重记录已就位。
- rememberLocalChat(topic, message)
- logDebug("SENT remember: topic=%q msg=%q", topic, message)
- err := s.SendNtfyMessage(topic, nickname, message)
- if err != nil {
- // 发送失败:移除刚记录的回声,避免误吞后续同主题同内容的真实消息
- forgetLocalChat(topic, message)
- return err
- }
- AddNtfyNickname(nickname) // 发送成功后记忆昵称
- return nil
- }
- // SendNtfyMessage 向指定主题推送一条 ntfy 消息。
- // topic 为推送主题(频道名),title 可为空(使用主题名),message 为消息内容。
- func (s *SettingsService) SendNtfyMessage(topic, title, message string) error {
- topic = strings.TrimSpace(topic)
- message = strings.TrimSpace(message)
- if topic == "" {
- return errors.New("请输入推送主题(Topic)")
- }
- if message == "" {
- return errors.New("请输入消息内容")
- }
- if len(topic) > 64 || len(message) > 4000 {
- return errors.New("主题过长或内容过长(主题 ≤64、内容 ≤4000 字符)")
- }
- // 校验主题为合法 URL 路径片段(允许字母数字、- _ .)
- for _, r := range topic {
- if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
- return errors.New("主题只能包含字母、数字、-、_、. 字符")
- }
- }
- body := bytes.NewBufferString(message)
- req, err := http.NewRequest(http.MethodPost, ntfyServer+"/"+url.PathEscape(topic), body)
- if err != nil {
- return err
- }
- req.Header.Set("Content-Type", "text/plain; charset=utf-8")
- if strings.TrimSpace(title) != "" {
- req.Header.Set("Title", strings.TrimSpace(title)) // 通知标题,缺省用主题名
- }
- client := &http.Client{Timeout: 15 * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return errors.New("推送请求失败:" + err.Error())
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
- return errors.New("推送失败,状态 " + resp.Status + ":" + strings.TrimSpace(string(raw)))
- }
- // 发送成功后记忆主题(去重置顶)
- AddNtfyTopic(topic)
- return nil
- }
- // GetNtfyTopics 返回已记忆的主题列表(用于前端下拉建议)
- func (s *SettingsService) GetNtfyTopics() []string {
- ntfyMu.Lock()
- defer ntfyMu.Unlock()
- return loadNtfyTopics()
- }
- // AddNtfyTopic 记录一个主题:去重、置顶、上限 ntfyTopicHistoryLimit 条,返回更新后列表。
- // 发送失败的主题不记录;仅在成功发送时调用。
- func AddNtfyTopic(topic string) []string {
- topic = strings.TrimSpace(topic)
- if topic == "" {
- return loadNtfyTopics()
- }
- ntfyMu.Lock()
- defer ntfyMu.Unlock()
- topics := loadNtfyTopics()
- next := []string{topic}
- for _, t := range topics {
- if t != topic {
- next = append(next, t)
- }
- }
- if len(next) > ntfyTopicHistoryLimit {
- next = next[:ntfyTopicHistoryLimit] // 只保留最近 N 条
- }
- if err := saveNtfyTopics(next); err != nil {
- // 持久化失败不影响本次返回(仅记录历史)
- return topics
- }
- return next
- }
- // ============================================================================
- // ntfy 聊天室:订阅一个主题,实时监听该主题收到的消息并广播给前端
- // ============================================================================
- // NtfyChatMessage 聊天室收到的一条消息(JSON 序列化后经 ntfyChat 事件发给前端)
- type NtfyChatMessage struct {
- Topic string `json:"topic"` // 所属主题(聊天室名)
- Title string `json:"title"` // 通知标题(可为空)
- Message string `json:"message"` // 消息内容
- Time string `json:"time"` // 收到时间(本地时区 HH:MM:SS)
- }
- // ntfyEchoRecent 记录近期本地发送的消息,用于去重 ntfy 的 SSE 回声:
- // 自己发送的消息会被 ntfy 经 SSE 回传一次,若不加去重会在前端重复显示。
- type ntfyEchoRecord struct {
- Topic string
- Message string
- At time.Time
- }
- var (
- ntfyEchoMu sync.Mutex
- ntfyEchoRecent []ntfyEchoRecord
- ntfyEchoWindow = 8 * time.Second // 8 秒内的同主题同内容消息视为回声
- ntfyEchoMax = 20 // 最多记忆条数
- )
- // rememberLocalChat 记录一次本地发送(由 SendNtfyChat 成功发送后调用)。
- func rememberLocalChat(topic, message string) {
- ntfyEchoMu.Lock()
- defer ntfyEchoMu.Unlock()
- now := time.Now()
- // 先清理过期记录,再追加
- valid := ntfyEchoRecent[:0]
- for _, r := range ntfyEchoRecent {
- if now.Sub(r.At) < ntfyEchoWindow {
- valid = append(valid, r)
- }
- }
- valid = append(valid, ntfyEchoRecord{Topic: topic, Message: message, At: now})
- if len(valid) > ntfyEchoMax {
- valid = valid[len(valid)-ntfyEchoMax:]
- }
- ntfyEchoRecent = valid
- }
- // consumeEchoChat 检查一条收到的 SSE 消息是否为本地发送的回声。
- // 若是(8 秒内同主题同内容),消费并返回 true,调用方应丢弃该消息。
- func consumeEchoChat(topic, message string) bool {
- ntfyEchoMu.Lock()
- defer ntfyEchoMu.Unlock()
- now := time.Now()
- for i, r := range ntfyEchoRecent {
- if now.Sub(r.At) < ntfyEchoWindow && r.Topic == topic && r.Message == message {
- // 移除该条,避免后续重复命中
- ntfyEchoRecent = append(ntfyEchoRecent[:i], ntfyEchoRecent[i+1:]...)
- return true
- }
- }
- return false
- }
- // forgetLocalChat 移除一条本地发送记录(发送失败时回滚)。
- // 若发送失败,SSE 不会收到回声,需移除记录避免误吞后续同主题同内容的真实消息。
- func forgetLocalChat(topic, message string) {
- ntfyEchoMu.Lock()
- defer ntfyEchoMu.Unlock()
- for i, r := range ntfyEchoRecent {
- if r.Topic == topic && r.Message == message {
- ntfyEchoRecent = append(ntfyEchoRecent[:i], ntfyEchoRecent[i+1:]...)
- return
- }
- }
- }
- // ntfyChatState 聊天室监听状态:当前订阅的主题与取消函数
- type ntfyChatState struct {
- mu sync.Mutex
- topic string // 当前监听的主题,空表示未监听
- cancel func() // 取消当前监听(关闭 HTTP 响应体)
- }
- var ntfyChat = &ntfyChatState{}
- // SetNtfyChatTopic 设置聊天室要监听的主题。
- // 传入空字符串则停止监听(无主题不监听)。
- // 若与当前监听主题相同则不做任何事;不同则先停止旧监听再启动新监听。
- func (s *SettingsService) SetNtfyChatTopic(topic string) error {
- topic = strings.TrimSpace(topic)
- if topic == "" {
- StopNtfyChat()
- return nil
- }
- // 主题合法性校验(与发送一致)
- for _, r := range topic {
- if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
- return errors.New("主题只能包含字母、数字、-、_、. 字符")
- }
- }
- ntfyChat.mu.Lock()
- defer ntfyChat.mu.Unlock()
- if ntfyChat.topic == topic {
- return nil // 已在监听该主题,无需重启
- }
- // 停止旧的监听
- if ntfyChat.cancel != nil {
- ntfyChat.cancel()
- ntfyChat.cancel = nil
- }
- // 启动新监听
- ctx, cancel := context.WithCancel(context.Background())
- ntfyChat.topic = topic
- ntfyChat.cancel = cancel
- go listenNtfyChat(ctx, topic)
- return nil
- }
- // StopNtfyChat 停止聊天室监听(清空主题并取消请求)
- func StopNtfyChat() {
- ntfyChat.mu.Lock()
- defer ntfyChat.mu.Unlock()
- if ntfyChat.cancel != nil {
- ntfyChat.cancel()
- ntfyChat.cancel = nil
- }
- ntfyChat.topic = ""
- }
- // GetNtfyChatTopic 返回当前监听的聊天室主题(空表示未监听)
- func (s *SettingsService) GetNtfyChatTopic() string {
- ntfyChat.mu.Lock()
- defer ntfyChat.mu.Unlock()
- return ntfyChat.topic
- }
- // listenNtfyChat 用 SSE 长连接订阅 ntfy 主题,循环读取消息并广播 ntfyChat 事件。
- // ctx 取消时关闭请求退出;连接中断后按 backoff 自动重连,直到被取消。
- func listenNtfyChat(ctx context.Context, topic string) {
- client := &http.Client{}
- backoff := time.Second
- for {
- select {
- case <-ctx.Done():
- return // 被取消,退出
- default:
- }
- err := ntfyChatOnce(client, ctx, topic, backoff)
- // 连接结束(错误或中断),增大重连间隔,若已被取消则退出
- select {
- case <-ctx.Done():
- return
- default:
- }
- if backoff < 30*time.Second {
- backoff += 5 * time.Second // 逐步增大重连间隔
- }
- _ = err
- }
- }
- // ntfyChatOnce 建立一次 SSE 连接并阻塞读取消息,直到连接结束或被取消。
- // 成功接收任意一条消息后重置 backoff(由调用方在收到消息时处理)。
- func ntfyChatOnce(client *http.Client, ctx context.Context, topic string, backoff time.Duration) error {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, ntfyServer+"/"+url.PathEscape(topic)+"/sse", nil)
- if err != nil {
- return err
- }
- req.Header.Set("Accept", "text/event-stream")
- resp, err := client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- // 非 2xx:不重试(如主题非法 / 服务拒绝),直接返回错误
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
- return fmt.Errorf("subscribe failed: %s %s", resp.Status, strings.TrimSpace(string(raw)))
- }
- scanner := bufio.NewScanner(resp.Body)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 单行最大 1MB,容纳长消息
- // SSE 字段:ntfy.sh 只发 data: 行(内含 JSON,事件类型在 JSON 的 event 键),
- // 没有独立的 event: 字段;空行分隔一条事件。keepalive 为注释行(: ping),不含 data:。
- var data string
- for scanner.Scan() {
- line := scanner.Text()
- if line == "" {
- // 一条 SSE 事件结束
- if data != "" {
- emitNtfyChat(topic, data)
- }
- data = ""
- continue
- }
- if strings.HasPrefix(line, "data:") {
- data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- }
- }
- return scanner.Err()
- }
- // emitNtfyChat 解析一条消息 data 并广播 ntfyChat 事件给前端。
- // ntfy 的 SSE data 为 JSON,内含 event 字段:只处理 message 事件,忽略 open/keepalive 等。
- func emitNtfyChat(topic, data string) {
- // data 可能是 JSON(发布时带标题 / event 字段)或纯文本。
- // 注意:ntfy JSON 的 time 字段是数字,必须用 map[string]any 解析,
- // 若用 map[string]string 会因 number→string 失败导致解析整个失败,
- // 从而把整条原始 JSON 当作消息回退显示(表现为「直接显示 ntfy 消息」)。
- var title, msg string
- showTime := time.Now().Format("15:04:05") // 默认用收到时间
- var raw map[string]any
- if json.Unmarshal([]byte(data), &raw) == nil {
- // 仅处理 message 事件;无 event 字段时按纯文本消息处理
- if ev, ok := raw["event"].(string); ok && ev != "message" {
- return // open / keepalive 等非消息事件,忽略
- }
- if v, ok := raw["message"].(string); ok {
- msg = v
- }
- if v, ok := raw["title"].(string); ok {
- title = v
- }
- // ntfy 的 time 为 Unix 时间戳(秒),转本地时间显示发送时刻
- if ts, ok := raw["time"].(float64); ok && ts > 0 {
- showTime = time.Unix(int64(ts), 0).Format("15:04:05")
- }
- }
- if msg == "" {
- msg = data // 纯文本消息
- }
- if title == "" {
- title = topic // 无标题时显示主题名
- }
- // 去重 SSE 回声:自己刚发送的消息会被 ntfy 回传一次,
- // 若 8 秒内同主题同内容则判定为回声,直接丢弃(本地已显示)。
- if consumeEchoChat(topic, msg) {
- logDebug("ECHO dropped: listenTopic=%q title=%q msg=%q", topic, title, msg)
- return
- }
- logDebug("ECHO broadcast: listenTopic=%q title=%q msg=%q", topic, title, msg)
- payload := NtfyChatMessage{
- Topic: topic,
- Title: title,
- Message: msg,
- Time: showTime,
- }
- b, _ := json.Marshal(payload)
- if app != nil {
- app.Event.Emit("ntfyChat", string(b))
- }
- }
- // logDebug 把调试日志追加写入 %TEMP%\ntfy_debug.log(临时诊断,定位后移除)。
- func logDebug(format string, args ...any) {
- dir := os.Getenv("TEMP")
- if dir == "" {
- dir = os.TempDir()
- }
- path := filepath.Join(dir, "ntfy_debug.log")
- f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return
- }
- defer f.Close()
- fmt.Fprintf(f, "%s %s\n", time.Now().Format("15:04:05.000"), fmt.Sprintf(format, args...))
- }
|