package webhook import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" ) const ArrWebhookPath = "/_arrtrix/webhook" var ( ErrNoManagementRoom = errors.New("no management room configured") ErrAmbiguousManagementRoom = errors.New("multiple management rooms configured") ) type payload struct { EventType string `json:"eventType"` Movie *movie `json:"movie"` MovieFile *movieFile `json:"movieFile"` IsUpgrade bool `json:"isUpgrade"` } type movie struct { Title string `json:"title"` Year int `json:"year"` ImdbID string `json:"imdbId"` TmdbID int `json:"tmdbId"` Path string `json:"path"` } type movieFile struct { Quality string `json:"quality"` RelativePath string `json:"relativePath"` SceneName string `json:"sceneName"` ReleaseGroup string `json:"releaseGroup"` } type roomResolver interface { ResolveManagementRoom(context.Context) (id.RoomID, error) } type noticeSender interface { SendNotice(context.Context, id.RoomID, string) error } type ArrHandler struct { resolver roomResolver sender noticeSender } func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge) error { if bridge == nil { return fmt.Errorf("bridge is not initialized") } handler := &ArrHandler{ resolver: bridgeRoomResolver{bridge: bridge}, sender: bridgeNoticeSender{bridge: bridge}, } router.Handle(fmt.Sprintf("POST %s", ArrWebhookPath), handler) return nil } func (h *ArrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var body payload if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid webhook payload", http.StatusBadRequest) return } if strings.TrimSpace(body.EventType) == "" { http.Error(w, "missing eventType", http.StatusBadRequest) return } roomID, err := h.resolver.ResolveManagementRoom(r.Context()) if err != nil { status := http.StatusInternalServerError if errors.Is(err, ErrNoManagementRoom) || errors.Is(err, ErrAmbiguousManagementRoom) { status = http.StatusConflict } http.Error(w, err.Error(), status) return } if err = h.sender.SendNotice(r.Context(), roomID, renderNotice(body)); err != nil { http.Error(w, "failed to deliver webhook", http.StatusBadGateway) return } w.WriteHeader(http.StatusAccepted) } type bridgeRoomResolver struct { bridge *bridgev2.Bridge } func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (id.RoomID, error) { rows, err := r.bridge.DB.Query(ctx, `SELECT mxid, management_room FROM "user" WHERE bridge_id=$1 AND management_room IS NOT NULL AND management_room <> ''`, r.bridge.ID) if err != nil { return "", fmt.Errorf("failed to query management rooms: %w", err) } defer rows.Close() var roomID id.RoomID var owners []id.UserID for rows.Next() { var mxid, managementRoom string if err = rows.Scan(&mxid, &managementRoom); err != nil { return "", fmt.Errorf("failed to scan management room: %w", err) } owners = append(owners, id.UserID(mxid)) if roomID == "" { roomID = id.RoomID(managementRoom) } } if err = rows.Err(); err != nil { return "", fmt.Errorf("failed to iterate management rooms: %w", err) } switch len(owners) { case 0: return "", ErrNoManagementRoom case 1: return roomID, nil default: return "", fmt.Errorf("%w: %s", ErrAmbiguousManagementRoom, strings.Join(convertUserIDs(owners), ", ")) } } type bridgeNoticeSender struct { bridge *bridgev2.Bridge } func (s bridgeNoticeSender) SendNotice(ctx context.Context, roomID id.RoomID, markdown string) error { if err := s.bridge.Bot.EnsureJoined(ctx, roomID); err != nil { return err } content := format.RenderMarkdown(markdown, true, false) _, err := s.bridge.Bot.SendMessage(ctx, roomID, event.EventMessage, &event.Content{Parsed: &content}, nil) return err } func renderNotice(body payload) string { title := "Arr" if body.Movie != nil { title = body.Movie.Title if body.Movie.Year != 0 { title = fmt.Sprintf("%s (%d)", title, body.Movie.Year) } } lines := []string{fmt.Sprintf("**Arr %s**", body.EventType)} if title != "Arr" { lines = append(lines, fmt.Sprintf("Movie: %s", title)) } if body.MovieFile != nil && body.MovieFile.Quality != "" { lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality)) } if body.MovieFile != nil && body.MovieFile.RelativePath != "" { lines = append(lines, fmt.Sprintf("File: `%s`", body.MovieFile.RelativePath)) } if body.EventType == "Download" { lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade)) } if body.Movie != nil && body.Movie.ImdbID != "" { lines = append(lines, fmt.Sprintf("IMDb: `%s`", body.Movie.ImdbID)) } return strings.Join(lines, "\n") } func convertUserIDs(users []id.UserID) []string { out := make([]string, len(users)) for i, user := range users { out[i] = string(user) } return out } var _ roomResolver = bridgeRoomResolver{} var _ noticeSender = bridgeNoticeSender{} var _ http.Handler = (*ArrHandler)(nil)