package server import ( "context" "errors" "fmt" "log/slog" "net/http" "slices" "strings" "time" "g.fc.run/misc/byre/internal/api" "g.fc.run/misc/byre/internal/store" ) func (s *Server) ListInbox(w http.ResponseWriter, r *http.Request, params api.ListInboxParams) { f := store.InboxFilter{State: api.Open} if params.ProjectId != nil { f.ProjectID = *params.ProjectId } if params.TaskId != nil { f.TaskID = *params.TaskId } if params.State != nil { f.State = *params.State } if params.IncludeSnoozed != nil { f.IncludeSnoozed = *params.IncludeSnoozed } items, err := s.store.ListInbox(r.Context(), f) if err != nil { writeStoreError(w, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) GetInboxItem(w http.ResponseWriter, r *http.Request, itemID string) { item, err := s.store.GetInboxItem(r.Context(), itemID) if err != nil { writeStoreError(w, err) return } writeJSON(w, http.StatusOK, item) } type httpError struct { status int err error } func (s *Server) RespondInbox(w http.ResponseWriter, r *http.Request) { req := api.InboxRespond{} if !readJSON(w, r, &req) { return } if len(req.Responses) == 0 { writeError(w, http.StatusBadRequest, errors.New("responses is empty")) return } resolutions := []store.Resolution{} sessions := []string{} seen := map[string]bool{} for _, create := range req.Responses { if seen[create.ItemId] { writeError(w, http.StatusBadRequest, fmt.Errorf("item %s answered twice in one submission", create.ItemId)) return } seen[create.ItemId] = true item, err := s.store.GetInboxItem(r.Context(), create.ItemId) if err != nil { writeStoreError(w, err) return } state, herr := checkResponse(item, create) if herr != nil { writeError(w, herr.status, herr.err) return } attachments := []api.Attachment{} if create.Attachments != nil { attachments = *create.Attachments } resolutions = append(resolutions, store.Resolution{ State: state, Response: api.InboxResponse{ Id: store.NewID(), ItemId: item.Id, User: actor(r), Type: create.Type, Option: create.Option, Text: create.Text, Attachments: attachments, SnoozeUntil: create.SnoozeUntil, CreatedAt: time.Now().UTC(), }, }) if !slices.Contains(sessions, item.AgentSessionId) { sessions = append(sessions, item.AgentSessionId) } } err := s.store.Respond(r.Context(), actor(r), resolutions) if err != nil { writeStoreError(w, err) return } for _, id := range sessions { go func() { err := s.agents.Deliver(context.Background(), id) if err != nil { slog.Error("deliver inbox responses", "session", id, "err", err) } }() } items := []api.InboxItem{} for _, res := range resolutions { item, err := s.store.GetInboxItem(r.Context(), res.Response.ItemId) if err != nil { writeStoreError(w, err) return } items = append(items, item) } writeJSON(w, http.StatusOK, items) } func checkResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxState, *httpError) { if item.State != api.Open { return "", &httpError{http.StatusConflict, fmt.Errorf("item %s is %s", item.Id, item.State)} } text := "" if c.Text != nil { text = strings.TrimSpace(*c.Text) } question := item.Kind == api.InboxItemKindQuestion bad := func(format string, args ...any) (api.InboxState, *httpError) { return "", &httpError{http.StatusBadRequest, fmt.Errorf("item %s: "+format, append([]any{item.Id}, args...)...)} } switch c.Type { case api.InboxResponseTypeOption: if !question { return bad("only questions have options") } if c.Option == nil || !hasOption(item, *c.Option) { return bad("option must be one of the question's options") } return api.Answered, nil case api.InboxResponseTypeFreeForm: if !question { return bad("only questions take answers") } if text == "" { return bad("free_form needs text") } return api.Answered, nil case api.InboxResponseTypeQuestion: if !question { return bad("only questions take follow-up questions") } if text == "" { return bad("question needs text") } return api.Open, nil case api.InboxResponseTypeRedirect: if text == "" { return bad("redirect needs text") } return api.Redirected, nil case api.InboxResponseTypeSnooze: if c.SnoozeUntil == nil || !c.SnoozeUntil.After(time.Now()) { return bad("snooze needs a future snooze_until") } return api.Open, nil case api.InboxResponseTypeDismiss: if question { return bad("questions need an answer or a redirect, not a dismissal") } return api.Dismissed, nil } return bad("unknown response type %q", c.Type) } func hasOption(item api.InboxItem, option string) bool { options, ok := item.Payload["options"].([]any) if !ok { return false } for _, o := range options { if o == option { return true } } return false }