// ============================================================================ // 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...)) }