package manager import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "git.veillax.com/Veillax/megalodon/internal/config" ) type httpDoer interface { Do(*http.Request) (*http.Response, error) } func defaultClient() httpDoer { return &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} } // defaultEvents is what a notification gets when it does not choose. var defaultEvents = map[string]bool{ "join": true, "leave": true, "online": true, "offline": true, "restart": true, "start": true, "ban": true, "unban": true, "kick": true, "filter": true, "watch": true, "error": true, } // notifier forwards events to the configured webhooks. func (m *Manager) notifier(ctx context.Context) { ch, cancel := m.hub.Subscribe() defer cancel() for { select { case <-ctx.Done(): return case e := <-ch: for _, n := range m.cfg.Get().Notify { if wants(n, e) { go func() { if err := m.send(ctx, n, e); err != nil { // Not published as an event: a broken webhook must not trigger itself. fmt.Printf("notify %s: %v\n", n.Name, err) } }() } } } } } func wants(n config.Notify, e Event) bool { if len(n.Servers) > 0 && e.Server != "" { ok := false for _, s := range n.Servers { ok = ok || s == e.Server } if !ok { return false } } if len(n.Events) == 0 { return defaultEvents[e.Type] } for _, t := range n.Events { if t == e.Type || t == "*" { return true } } return false } // TestNotify sends a sample message through the named notification. func (m *Manager) TestNotify(name string) error { for _, n := range m.cfg.Get().Notify { if n.Name == name { return m.send(m.ctx, n, Event{Time: time.Now(), Type: "audit", Message: "This is a test message from Megalodon."}) } } return errors.New("no such notification") } func (m *Manager) send(ctx context.Context, n config.Notify, e Event) error { server := e.Server for _, s := range m.cfg.Get().Servers { if s.ID == e.Server { server = s.Name } } text := e.Message if e.Type == "chat" { text = e.Actor + ": " + e.Message } var body []byte ct := "application/json" hdr := map[string]string{} switch n.Kind { case "discord": prefix := "" if server != "" { prefix = "**" + server + "** ยท " } body, _ = json.Marshal(map[string]any{"username": "Megalodon", "content": truncate(prefix+text, 1900), "allowed_mentions": map[string]any{"parse": []string{}}}) case "ntfy": body, ct = []byte(text), "text/plain" if server != "" { hdr["Title"] = server } hdr["Tags"] = e.Type default: body, _ = json.Marshal(e) } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "POST", n.URL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", ct) req.Header.Set("User-Agent", "megalodon") for k, v := range hdr { req.Header.Set(k, v) } resp, err := m.client.Do(req) if err != nil { return err } defer resp.Body.Close() io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) if resp.StatusCode >= 300 { return fmt.Errorf("%s answered %s", hostOf(n.URL), resp.Status) } return nil } func hostOf(u string) string { u = strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://") if i := strings.IndexAny(u, "/?"); i >= 0 { u = u[:i] } return u } func truncate(s string, n int) string { if r := []rune(s); len(r) > n { return string(r[:n]) } return s }